@adrienlcp/styles
A reset, a reduced-motion switch, default tokens, Sass mixins for self-hosted fonts, container queries, a breakpoint, focus rings, visually hidden text and a two-column spread, and a WCAG contrast check for colour tokens
Works with
- TypeScript
- CSS
- Sassoptional peer
Install
pnpm add @adrienlcp/stylesExports
Grouped by the section of the documentation that explains them. A row leads to its section.
Plain CSS2 exports
containers3 exports
containerMakes the element a container its descendants query by inline size.@adrienlcp/Sassstyles/ containers container-wideMatches when the nearest container — or the one named — is at least$widthwide.@adrienlcp/Sassstyles/ containers container-narrowThe exact complement ofcontainer-widefor the same$widthand$name.@adrienlcp/Sassstyles/ containers
breakpoints3 exports
fonts3 exports
tokens1 export
accessibility3 exports
spread1 export
contrast2 exports
Documentation
The README and the documents beside it, as written in the repository.
OverviewREADME.md
The styling floor every app of mine starts from: a reset, a reduced-motion
switch, and the Sass mixins that CSS cannot write yet. Plain .css for what
CSS can say, .sass only for what it cannot — a custom property is not allowed
in a media query or in unicode-range, and CSS has no mixins.
pnpm add @adrienlcp/stylesPlain CSSREADME.md
| File | Does |
|---|---|
reset.css |
Box sizing, zeroed margins and paddings, inherited fonts on controls, bare buttons and lists, balanced headings, pretty paragraphs, and interpolate-size: allow-keywords so a transition reaches height: auto (Chromium; elsewhere the size snaps as before). Inside @layer reset |
reduced-motion.css |
Collapses --transition-fast, --transition-base and --transition-slow to 0ms under prefers-reduced-motion: reduce, and stills view transitions, which React's <ViewTransition> starts whatever the preference. Unlayered, so it beats the tokens wherever they are defined |
Import them once — from JavaScript, or from the global stylesheet in Sass:
@use '@adrienlcp/styles/reset.css'
@use '@adrienlcp/styles/reduced-motion.css'reset.css declares a layer: set the order in the document head before any
stylesheet loads, so the reset stays under every component rule —
<style>@layer reset, tokens, base, components;</style>.
SassREADME.md
Resolved through the sass export condition, which Vite reads; with the Sass
CLI, use pkg:@adrienlcp/styles/breakpoints and --pkg-importer=node.
containersREADME.md
A component answers the room it is given, not the screen: the same card lays out the same way in a sidebar on a desktop and full width on a phone.
@use '@adrienlcp/styles/containers'
.card
@include containers.container
.card-body
@include containers.container-wide(30rem)
grid-template-columns: auto 1frcontainer($name: null)setscontainer-type: inline-size, andcontainer-namewhen given one.container-wide($width, $name: null)iswidth >= $widthon the nearest container, or on the one named;container-narrowis its exact complement.- An element queries an ancestor, never itself — the container wraps what changes.
- A container no longer takes its width from its content: inside a flex row
without a width, an
autogrid track or an absolutely positioned box, it collapses to zero. Its parent gives it a width. - With no container above, neither mixin matches. Make
bodyone in the global stylesheet, so an unwrapped component falls back to the page width;position: fixedstays on the viewport. - The width is Sass interpolated into
@container, so pass a value, not a custom property —@containercannot read one either.
breakpointsREADME.md
For what depends on the device rather than the room a component has: the page shell, a sidebar that turns into a drawer, an overlay pinned to the viewport.
@use '@adrienlcp/styles/breakpoints'
.shell
@include breakpoints.wide
grid-template-columns: 16rem 1fr$wide-screen is 900px; wide is width >= $wide-screen and narrow its
exact complement. Another value: @use '@adrienlcp/styles/breakpoints' with ($wide-screen: 1024px).
fontsREADME.md
$latin and $latin-ext are the unicode ranges Google Fonts cuts a Latin face
into; font-face declares one self-hosted woff2 file.
@use '@adrienlcp/styles/fonts'
@include fonts.font-face('Archivo', '/fonts/archivo-latin.woff2', fonts.$latin, $weight: 100 900, $stretch: 62% 125%)
@include fonts.font-face('Archivo', '/fonts/archivo-latin-ext.woff2', fonts.$latin-ext, $weight: 100 900, $stretch: 62% 125%)$weight, $style (normal), $stretch (left out) and $display (swap)
are optional.
tokensREADME.md
The tokens every app names the same way, so an app sets values, not names.
Include defaults first in the app's :root; what the app declares after it
wins.
@use '@adrienlcp/styles/tokens'
@layer tokens
:root
@include tokens.defaults
--measure: 62ch| Token | Default |
|---|---|
--stroke-hair, --stroke-thin, --stroke-bold |
1px, 1.5px, 2px |
--hairline, --hairline-strong |
a --stroke-hair solid line in --rule, --rule-strong |
--inset-hairline, --inset-hairline-strong |
the same line as an inset box-shadow, which takes no room |
--outline-thick, --outline-offset |
2px, 3px |
--ring, --ring-offset, --ring-inset |
an --outline-thick solid outline in --focus, drawn --outline-offset outside the box or inside it |
--underline-offset, --tracking-tight |
0.24em, -0.02em |
--target, --control-touch |
44px, the smallest touch target |
--measure |
65ch |
--rule, --rule-strong and --focus are the app's palette. Until it declares
them, a line is currentColor mixed toward transparent and the ring is
currentColor: never invisible.
accessibilityREADME.md
@use '@adrienlcp/styles/accessibility'
.icon-label
@include accessibility.visually-hidden
.button
@include accessibility.ringvisually-hiddenhides from sight, not from a screen reader.ringdraws--ringon keyboard focus only::focus-visiblefor a native element,[data-focus-visible]for one react-aria marks.ring-insetdraws it inside the box, for a row that fills its container edge to edge or sits in an ancestor that clips. An app on@adrienlcp/react-ariahas the same pair in itsfocusmodule.
spreadREADME.md
A page of two columns: .columns holding two .columns, stacked and ruled
apart under breakpoints.wide, side by side above it with the rule running the
full height between them.
@use '@adrienlcp/styles/spread'
.settings-page
@include spread.columns($gap: var(--space-m))$gap spaces a column's own items; $rule (--hairline), $stacked-gap
(--space-l) and $spread-gap (--space-2xl) are optional.
TypeScriptREADME.md
contrastREADME.md
A palette is checked where it is written, not by eye: a test lists the token pairs that meet on screen, and fails when one falls under its WCAG minimum in the light or the dark scheme.
import { readFileSync } from 'node:fs'
import { findContrastFailures, WCAG_AA } from '@adrienlcp/styles/contrast'
import { expect, it } from 'vitest'
const TOKENS = readFileSync(new URL('_tokens.sass', import.meta.url), 'utf8')
it('every ink reads on every surface, in both themes', () => {
expect(
findContrastFailures(TOKENS, [
{ foreground: '--ink-soft', background: '--ground', minimum: WCAG_AA.text },
{ foreground: '--focus', background: '--ground', minimum: WCAG_AA.nonText }
])
).toEqual([])
})WCAG_AAistext(4.5),largeTextandnonText(3) — controls, icons, focus rings.- The stylesheet is
.cssor indented.sass. A token isoklch()or hex,light-dark()of those, orvar()of another token. - A failure is
too-low, with the ratio and the schemes it fails in, orunreadable: a token undeclared, declared twice with different values, a reference loop, a colour it cannot read (computed, or another notation), or a translucent background — what shows through decides that contrast. - A translucent foreground is measured over its background. An
oklch()outside sRGB is clipped, as an sRGB screen draws it.