Install
One stylesheet. Use the bundled file in production, the source entry while developing.
Build dist/phi.css with npm run build. It runs every check first and refuses to build if one fails.
A layered CSS design system: a Fibonacci-snapped 8px grid, a √φ type scale, OKLCH color with verified contrast in every theme, and native-first components. No build step and no JavaScript dependency.
One stylesheet. Use the bundled file in production, the source entry while developing.
Build dist/phi.css with npm run build. It runs every check first and refuses to build if one fails.
A minimal accessible starting point.
Your styles win without !important. Either leave them unlayered, or join the layer stack.
Use semantic tokens (--color-*, --space-*, --step-*) rather than raw values, so your CSS follows the theme, contrast mode and brand automatically.
Chrome / Edge 123+, Safari 18+, Firefox 128+.
| Feature | Used for | Without it |
|---|---|---|
Relative color syntax, exp() | Brand ramp from --brand | Required |
light-dark(), @layer, :has() | Themes, cascade, state | Required |
| Anchor positioning | Menu placement | Menu opens centred |
@starting-style | Enter animations | Elements appear instantly |
interpolate-size | Disclosure height animation | Opens instantly |
closedby | Click outside to close a dialog | Esc and buttons still work |
Four rules explain almost everything else.
Later layers always win, regardless of selector specificity.
| Layer | Contains |
|---|---|
reset | Box sizing, margins, media defaults |
tokens | All custom properties: primitive, semantic, scale; theme and contrast mappings |
base | Element defaults: headings, links, lists, tables, focus ring |
layout | Space between elements (stack, grid, sidebar…) |
components | Space and look inside elements (button, card, dialog…) |
utilities | Single-purpose overrides: .hidden beats .btn[data-variant="primary"] |
!important is banned. Inside layers it reverses priority, so the reset layer would win.
Primitive values feed semantic tokens, which feed component tokens. Your code uses the middle tier.
| Tier | Example | Who may use it |
|---|---|---|
| Primitive | --brand-700, --neutral-900 | Token files only (enforced by lint) |
| Semantic | --color-action, --space-md | Everyone. This is the contract. |
| Component | --btn-bg, --card-pad | Override points for one component |
There are no state classes like .is-active. Components read the attribute that assistive tech reads, so wrong semantics look wrong.
| State | Write this |
|---|---|
| Current page / link | aria-current="page" |
| Selected tab | aria-selected="true" |
| Invalid input | aria-invalid="true" + aria-describedby |
| Disabled | disabled (buttons, inputs); for links remove href and add role="link" aria-disabled="true" |
| Checked / on | checked (native :checked) |
| Open | <details open>, dialog.showModal(), popover |
Use at least 3 characters.
Remove aria-current, aria-invalid, checked or disabled and the styling goes with it. No class to keep in sync.
Every size lands on a 4px / 8px grid, so any stack of elements stays aligned.
.debug-baseline to any element to overlay the grid.Every line box is a multiple of 4px, so text, controls and badges all land on the lines.
Themes, contrast and brand are all token remapping. Component CSS never changes.
Follows the OS by default. Force a theme on any element with data-theme.
Each semantic color is declared once as light-dark(light, dark) and resolved by color-scheme, so native controls and scrollbars switch too.
Three levels, all automatic.
| Mode | Trigger | Result |
|---|---|---|
| Standard | default | Text ≥ 4.5:1, UI ≥ 3:1 (WCAG AA) |
| High contrast | prefers-contrast: more, or <html data-contrast="more"> | Text ≥ 7:1 (AAA), solid 2px borders, no shadows |
| Forced colors | Windows High Contrast etc. | Tokens map to system colors; containers keep a visible edge |
Borders turn solid and shadows disappear in high contrast.
Opt a page out of the OS high-contrast preference with data-contrast="standard" only if you provide your own setting. Forced colors cannot be emulated here; test with Windows Contrast themes or the browser's DevTools rendering panel.
Write one line of CSS. The whole brand ramp is derived in the browser; no tools needed.
Give one section its own brand color with .brand-scope.
The class is required. Custom properties resolve var() where they are declared, so the brand-derived tokens must be declared again on the scope element. High-contrast and forced-color modes cover scopes too.
With prefers-reduced-motion: reduce, every --duration-* token becomes 0ms.
Use the duration tokens in your own transitions and you get this for free.
The semantic and scale tokens you can use anywhere.
Fibonacci numbers snapped to the 8px grid.
| Token | Value | Fibonacci | Scale |
|---|---|---|---|
--space-3xs | 4px | F5 = 5 → 4 | |
--space-2xs | 8px | F6 = 8 | |
--space-xs | 16px | F7 = 13 → 16 | |
--space-sm | 24px | F8 = 21 → 24 | |
--space-md | 32px | F9 = 34 → 32 | |
--space-lg | 56px | F10 = 55 → 56 | |
--space-xl | 88px | F11 = 89 → 88 | |
--space-2xl | 144px | F12 = 144 |
16 × 1.272ⁿ rounded to 4px. Always pair a step with its leading.
| Size / line | px | Default use |
|---|---|---|
--step-6 / --leading-6 | 68 / 80 | .display |
--step-5 / --leading-5 | 52 / 64 | h1 |
--step-4 / --leading-4 | 40 / 48 | h2 |
--step-3 / --leading-3 | 32 / 40 | h3 |
--step-2 / --leading-2 | 24 / 32 | h4, card and dialog titles |
--step-1 / --leading-1 | 20 / 28 | h5, .lede |
--step-0 / --leading-0 | 16 / 24 | Body |
--step--1 / --leading--1 | 12 / 20 | small, captions, badges |
In markup, the .step-N utilities set both at once: <p class="step-2">.
Also: --font-sans, --font-mono, --weight-regular | medium | semibold | bold, --measure (66ch).
| Tokens | Values |
|---|---|
--radius-sm | md | lg | pill | 4 · 8 · 16px · 999px |
--control-sm | md | lg | 32 · 40 · 56px (buttons, inputs) |
--border-width, --focus-width | 1px, 2px (2px, 3px in high contrast) |
--container-max | 72rem (1152px) |
--duration-fast | base | slow | 120 · 200 · 320ms (0 with reduced motion) |
--ease-out | cubic-bezier(0.2, 0.8, 0.2, 1) |
--z-sticky | toast | skip-link | 100 · 400 · 500. Dialogs and popovers use the top layer and need none. |
--phi, --phi-major, --phi-minor | 1.618, 61.8%, 38.2% |
Use these. Each one adapts to theme, contrast mode, forced colors and brand.
| Token | Swatch | Use for |
|---|---|---|
--color-surface | Page background | |
--color-surface-raised | Cards, dialogs, menus | |
--color-surface-sunken | Wells, bands, striped rows | |
--color-field | Input backgrounds | |
--color-text-main | Body text | |
--color-text-muted | Secondary text (still ≥ 4.5:1) | |
--color-link, -hover | Links (brand-derived) | |
--color-border-subtle | Decorative dividers only | |
--color-border | Control edges (≥ 3:1) | |
--color-border-strong | Emphasis edges | |
--color-action, -hover, -active | Primary actions (brand-derived) | |
--color-on-action | Text on action backgrounds | |
--color-action-subtle | Hover, selected rows, current nav item | |
--color-danger, --color-on-danger | Destructive actions | |
--color-focus | Focus ring | |
--color-{success|warning|error|info}-{fg|bg|border} | Status text, tint, edge | |
--color-highlight, --color-on-highlight | <mark> | |
--color-disabled-bg, -text | Disabled controls | |
--color-backdrop | Modal scrim | |
--chart-1 … --chart-6 | Data series (Okabe-Ito, luminance-staggered) | |
--shadow-raised, --shadow-overlay | — | Elevation (removed in high contrast) |
Never use primitives such as --brand-700 or raw values: only semantic tokens follow theme, contrast mode, forced colors and brand.
Primitives respond to their own container, not the viewport. Resize the dashed examples to see them adapt.
Centred page width with side padding (16px, 32px from 48rem).
Tokens: --container-pad. Max width is --container-max.
Vertical rhythm between children.
data-gap: 2xs | sm | md | lg | xl | 2xl (default xs, 16px). Token: --stack-gap.
A wrapping row, e.g. buttons or tags.
data-gap: xs | sm (default 8px). Token: --cluster-gap.
All children in one row above a threshold, all stacked below it. Never a 2 + 1 split.
Tokens: --switcher-threshold (40rem), --switcher-gap. Five or more children always stack.
Spacing for long-form content: more space above headings, less below.
Paragraphs are spaced 16px apart and limited to a readable measure.
Use it for articles, help pages and rendered Markdown.
…and only 8px below, so it clearly belongs to the text that follows.
Applies to direct children. Headings (h2–h4) get --space-lg above and --space-2xs below. Token: --flow-space.
Two columns in exactly φ : 1 (61.8% / 38.2%). Wraps when they no longer fit.
data-flip puts the minor column first. Tokens: --golden-min (15rem minor basis), --golden-gap.
Fixed-width side column plus a fluid main column. Wraps when main would drop below 61.8%.
data-side="end" makes the last child the sidebar. Tokens: --sidebar-width (15rem), --sidebar-content-min, --sidebar-gap.
As many columns as fit. Cards with data-subgrid align title, body and actions across the row.
For one person.
Shared tokens, review flow and history for up to twenty people.
data-min: sm (9rem) or lg (24rem); default 15rem. Tokens: --grid-min, --grid-gap, and --card-rows (default 3) on subgrid cards.
Aspect-ratio box, golden (1.618 : 1) by default. Media inside is cropped to fill.
data-ratio: portrait (1 : 1.618) or square. Token: --frame-ratio.
Full-height block: first and last children pin to the edges, .cover__principal centres.
Tokens: --cover-min (100svh), --cover-pad.
Horizontal scroll-snap strip. Each item is 61.8% wide so the next one peeks.
Always add role="region", a label and tabindex="0" so keyboard users can scroll it. data-item="sm" gives 38.2% items. Tokens: --reel-item, --reel-gap.
Four tiles in the Fibonacci subdivision of a golden rectangle (89², 55², 34², 21×34). Single column below 36rem.
Needs both elements: the outer one measures its width. Token: --bento-gap.
Page skeleton: sticky header, side nav, main, optional aside. This page uses it.
Single column below 48rem; nav + main from 48rem; three columns from 80rem when an aside exists. Tokens: --shell-nav, --shell-aside, --shell-header, --shell-min. The nav is never hidden automatically; collapsing it is up to you.
Native elements first. No JavaScript unless the entry says so.
Works on <button> and <a>.
| Hook | Values | Notes |
|---|---|---|
data-variant | primary | ghost | danger | Omit for secondary |
data-size | sm | lg | 32 / 56px tall (default 40px) |
data-shape | icon | Square; needs aria-label |
disabled / aria-disabled | On a link: remove href, add role="link" aria-disabled="true". aria-disabled alone does not stop activation. | |
| Tokens | --btn-bg, --btn-fg, --btn-border, --btn-height, --btn-pad-x, --btn-radius | |
Mask-drawn glyphs that take the current text color.
data-icon: close, chevron-down, chevron-start, chevron-end, check, plus, menu, more, success, warning, error, info. Size with --icon-size (1.25em). Icons are decorative: always aria-hidden="true", with the name on the control. Chevrons mirror in right-to-left text.
A raised container. The border is transparent but real, so forced-color modes still draw its edge.
Body text.
data-variant: outlined | sunken (default raised). Parts: __title, __meta, __actions (sinks to the bottom). data-subgrid: see Grid. Tokens: --card-pad, --card-gap, --card-bg, --card-border, --card-shadow, --card-radius.
In-page status message. Color is always paired with a shape and a text label.
Warning: quota almost used
You have used 90% of this month's storage.
data-status: success (circle), warning (triangle), error (octagon), info (square). Start the title with the status word. Use role="alert" only for urgent errors.
Small inline status label. Neutral without data-status.
Label, control, hint and error. The invalid state is a thicker edge, an icon and a message, never color alone.
We only use it for receipts.
Enter a complete address, e.g. name@example.com.
.input works on input, textarea and select. data-size="sm" for 32px. Link hint and error with aria-describedby.
Native controls, sized to 20px and tinted with the brand.
Group with fieldset.choice-group + legend. data-inline lays the options out in a row.
A checkbox with role="switch". The thumb position shows state, not color alone.
Tokens: --switch-w, --switch-h.
Radios styled as joined buttons. Arrow keys work natively.
Plain tables are styled already; .table adds options. Wide tables scroll inside their own region.
| Order | Status | Total | |
|---|---|---|---|
| #1042 | Paid | $128.00 | |
| #1041 | Pending | $34.50 |
| Hook | Effect |
|---|---|
data-density="compact" | 28px rows instead of 40px |
data-striped, data-hover | Zebra rows, row hover |
data-sticky-header | Header sticks inside .table-scroll[data-max-height] (--table-max-height, 24rem) |
th/td[data-numeric] | Right-aligned tabular figures |
th/td[data-select] | Checkbox column; a checked box selects its row (fill + edge bar) |
tr[aria-selected="true"] | Selected row, only inside role="grid" |
Give the scroll region role="region", aria-label and tabindex="0". The header checkbox needs your JS to select all rows.
First focusable element on the page; visible only when focused.
This page has one. Reload and press Tab, or:
Brand, links and actions. The current page gets a thick underline.
WAI-ARIA tabs. CSS styles the state; a few lines of JS handle selection and arrow keys.
Separators are decorative and hidden from screen readers.
40px targets. Mark the current page with aria-current.
An unavailable link has no href, plus role="link" and aria-disabled="true".
Vertical navigation. The current item gets a fill and an edge bar. This page's sidebar uses it.
Native <details>. The same name makes an exclusive accordion without JS.
Native <dialog>: top layer, focus trap, Esc and focus return are built in.
data-size: sm | lg. data-variant="drawer": full-height sheet from the inline end. closedby="any" adds click-outside to close. The page stops scrolling while it is open. Token: --dialog-width.
An action list in a native popover: light dismiss, Esc and focus return with no JS. Anchored under its button.
Items are plain buttons and links reached with Tab. For full role="menu" arrow-key behaviour, add JS; the styles stay the same. Token: --menu-min.
Transient notification in a live region.
popover="manual" if they must float above.Eyebrow → display headline → lede → call to action, next to a golden frame.
Use h1 for the headline on a real page (the example uses h2 because this page already has one). Put an img, video or svg inside the .frame.
Styles for your SVG. Each series is encoded by color, dash, marker shape and fill texture, and labelled directly.
| Hook | Effect |
|---|---|
[data-series="1…6"] | Sets color, dash pattern and fill texture for everything inside |
.chart__line | Series stroke with its dash pattern |
.chart__bar, .chart__area | Textured fill (url(#phi-fill-N)) |
<use class="chart__marker" href="#phi-marker-N"> | Series marker shape |
.chart__label, .chart__tick | Direct label (main text color) and axis text |
.chart__grid, .chart__axis | Grid and axis lines |
Copy the <svg class="chart-defs"> sprite from this page's source into your page once. Give every chart a text summary (aria-label) and ideally a data table. Use at most six series.
Top layer: each one wins over any component.
| Class | Effect |
|---|---|
.hidden, [hidden] | display: none, even on components |
.visually-hidden | Hidden on screen, still read by screen readers |
.text-muted, .text-center, .text-balance | Text color and alignment |
.measure | Max width of a readable line (66ch) |
.step--1 … .step-6 | Font size with its matching line height |
.surface, .surface-sunken | Background color |
.debug-baseline | Overlay the 4px / 8px grid |
.visually-hidden text is an alternative to aria-label for icon-only buttons. .hidden removes an element for everyone, screen readers included.
What φ-css handles, and what your markup still has to do.
lang on <html> and add a skip link.<label>, every icon-only button an aria-label, and every chart a text summary.aria-current, aria-selected, aria-invalid, aria-describedby.role="region", aria-label, tabindex="0".For contributors. Every rule below fails the build.
| Command | Does |
|---|---|
npm run check | Lint + contrast checks |
npm run build | Check, then write dist/phi.css |
npm run gen | Regenerate primitive tokens and brand formulas |
npm run dev | Serve /demo/ and /docs/ on port 4173 |
!important.src/tokens/.src/tokens/.var(--x) must be defined, or have a fallback.src/layout/: layout properties only, logical properties only, no color or type tokens..is-active, .selected…); use ARIA and native state.Every semantic pairing in light and dark, standard and high contrast; the lightness-ladder invariants; chart luminance staggering; and an exhaustive brand sweep (every hue × chroma, all inside sRGB).