φ-css docs
Color theme

φ-css

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.

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.

Page template

A minimal accessible starting point.

Adding your own CSS

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.

Browser support

Chrome / Edge 123+, Safari 18+, Firefox 128+.

FeatureUsed forWithout it
Relative color syntax, exp()Brand ramp from --brandRequired
light-dark(), @layer, :has()Themes, cascade, stateRequired
Anchor positioningMenu placementMenu opens centred
@starting-styleEnter animationsElements appear instantly
interpolate-sizeDisclosure height animationOpens instantly
closedbyClick outside to close a dialogEsc and buttons still work

Concepts

Four rules explain almost everything else.

Cascade layers

Later layers always win, regardless of selector specificity.

LayerContains
resetBox sizing, margins, media defaults
tokensAll custom properties: primitive, semantic, scale; theme and contrast mappings
baseElement defaults: headings, links, lists, tables, focus ring
layoutSpace between elements (stack, grid, sidebar…)
componentsSpace and look inside elements (button, card, dialog…)
utilitiesSingle-purpose overrides: .hidden beats .btn[data-variant="primary"]

!important is banned. Inside layers it reverses priority, so the reset layer would win.

Token tiers

Primitive values feed semantic tokens, which feed component tokens. Your code uses the middle tier.

TierExampleWho may use it
Primitive--brand-700, --neutral-900Token files only (enforced by lint)
Semantic--color-action, --space-mdEveryone. This is the contract.
Component--btn-bg, --card-padOverride points for one component

State comes from ARIA

There are no state classes like .is-active. Components read the attribute that assistive tech reads, so wrong semantics look wrong.

StateWrite this
Current page / linkaria-current="page"
Selected tabaria-selected="true"
Invalid inputaria-invalid="true" + aria-describedby
Disableddisabled (buttons, inputs); for links remove href and add role="link" aria-disabled="true"
Checked / onchecked (native :checked)
Open<details open>, dialog.showModal(), popover

Example: the attribute is the style

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.

Grid & rhythm

Every size lands on a 4px / 8px grid, so any stack of elements stays aligned.

  • Spacing follows Fibonacci numbers snapped to 8px: 8, 16, 24, 32, 56, 88, 144.
  • Type follows 16 × 1.272ⁿ (√φ) rounded to 4px; every line height is a multiple of 4px.
  • Bordered components subtract the border from their padding, so the outer box stays on the grid.
  • Add .debug-baseline to any element to overlay the grid.

Heading on the grid

Every line box is a multiple of 4px, so text, controls and badges all land on the lines.

Badge · 24px

Theming

Themes, contrast and brand are all token remapping. Component CSS never changes.

Light & dark

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.

Contrast & forced colors

Three levels, all automatic.

ModeTriggerResult
StandarddefaultText ≥ 4.5:1, UI ≥ 3:1 (WCAG AA)
High contrastprefers-contrast: more, or <html data-contrast="more">Text ≥ 7:1 (AAA), solid 2px borders, no shadows
Forced colorsWindows High Contrast etc.Tokens map to system colors; containers keep a visible edge
Contrast level for this page
A link Muted text Pending

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.

Brand color

Write one line of CSS. The whole brand ramp is derived in the browser; no tools needed.

Chroma
A link
  • Hue comes from your color. Lightness comes from the fixed ladder, so contrast never changes. Chroma is capped to what the screen can show for that hue.
  • Every possible input is checked in CI: 720 hues × 21 chroma levels, both themes, both contrast levels. Any brand color is safe.
  • Your exact hex will not appear on buttons: bright brands (orange, yellow, cyan) become darker at step 700.
  • Keep your brand hue away from the status hues (red ≈ 12°, amber ≈ 66°, teal ≈ 165°) so primary and danger buttons don't look alike.

Brand scope

Give one section its own brand color with .brand-scope.

Scoped link

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.

Reduced motion

With prefers-reduced-motion: reduce, every --duration-* token becomes 0ms.

Use the duration tokens in your own transitions and you get this for free.

Tokens

The semantic and scale tokens you can use anywhere.

Space

Fibonacci numbers snapped to the 8px grid.

TokenValueFibonacciScale
--space-3xs4pxF5 = 5 → 4
--space-2xs8pxF6 = 8
--space-xs16pxF7 = 13 → 16
--space-sm24pxF8 = 21 → 24
--space-md32pxF9 = 34 → 32
--space-lg56pxF10 = 55 → 56
--space-xl88pxF11 = 89 → 88
--space-2xl144pxF12 = 144

Type

16 × 1.272ⁿ rounded to 4px. Always pair a step with its leading.

Size / linepxDefault use
--step-6 / --leading-668 / 80.display
--step-5 / --leading-552 / 64h1
--step-4 / --leading-440 / 48h2
--step-3 / --leading-332 / 40h3
--step-2 / --leading-224 / 32h4, card and dialog titles
--step-1 / --leading-120 / 28h5, .lede
--step-0 / --leading-016 / 24Body
--step--1 / --leading--112 / 20small, 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).

Shape, size, motion

TokensValues
--radius-sm | md | lg | pill4 · 8 · 16px · 999px
--control-sm | md | lg32 · 40 · 56px (buttons, inputs)
--border-width, --focus-width1px, 2px (2px, 3px in high contrast)
--container-max72rem (1152px)
--duration-fast | base | slow120 · 200 · 320ms (0 with reduced motion)
--ease-outcubic-bezier(0.2, 0.8, 0.2, 1)
--z-sticky | toast | skip-link100 · 400 · 500. Dialogs and popovers use the top layer and need none.
--phi, --phi-major, --phi-minor1.618, 61.8%, 38.2%

Color

Use these. Each one adapts to theme, contrast mode, forced colors and brand.

TokenSwatchUse for
--color-surfacePage background
--color-surface-raisedCards, dialogs, menus
--color-surface-sunkenWells, bands, striped rows
--color-fieldInput backgrounds
--color-text-mainBody text
--color-text-mutedSecondary text (still ≥ 4.5:1)
--color-link, -hoverLinks (brand-derived)
--color-border-subtleDecorative dividers only
--color-borderControl edges (≥ 3:1)
--color-border-strongEmphasis edges
--color-action, -hover, -activePrimary actions (brand-derived)
--color-on-actionText on action backgrounds
--color-action-subtleHover, selected rows, current nav item
--color-danger, --color-on-dangerDestructive actions
--color-focusFocus ring
--color-{success|warning|error|info}-{fg|bg|border}Status text, tint, edge
--color-highlight, --color-on-highlight<mark>
--color-disabled-bg, -textDisabled controls
--color-backdropModal scrim
--chart-1 … --chart-6Data 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.

Layout

Primitives respond to their own container, not the viewport. Resize the dashed examples to see them adapt.

Container

Centred page width with side padding (16px, 32px from 48rem).

Tokens: --container-pad. Max width is --container-max.

Stack

Vertical rhythm between children.

One
Two

data-gap: 2xs | sm | md | lg | xl | 2xl (default xs, 16px). Token: --stack-gap.

Cluster

A wrapping row, e.g. buttons or tags.

OneTwoThree

data-gap: xs | sm (default 8px). Token: --cluster-gap.

Switcher

All children in one row above a threshold, all stacked below it. Never a 2 + 1 split.

A
B
C

Tokens: --switcher-threshold (40rem), --switcher-gap. Five or more children always stack.

Flow

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.

A heading gets 56px above

…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.

Golden split

Two columns in exactly φ : 1 (61.8% / 38.2%). Wraps when they no longer fit.

Major · 61.8%
Minor · 38.2%

data-flip puts the minor column first. Tokens: --golden-min (15rem minor basis), --golden-gap.

Grid

As many columns as fit. Cards with data-subgrid align title, body and actions across the row.

Starter

For one person.

Team

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.

Frame

Aspect-ratio box, golden (1.618 : 1) by default. Media inside is cropped to fill.

1.618 : 1
1 : 1.618
1 : 1

data-ratio: portrait (1 : 1.618) or square. Token: --frame-ratio.

Cover

Full-height block: first and last children pin to the edges, .cover__principal centres.

Header
Principal
Footer

Tokens: --cover-min (100svh), --cover-pad.

Reel

Horizontal scroll-snap strip. Each item is 61.8% wide so the next one peeks.

1
2
3
4

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.

Bento golden

Four tiles in the Fibonacci subdivision of a golden rectangle (89², 55², 34², 21×34). Single column below 36rem.

a
b
c
d

Needs both elements: the outer one measures its width. Token: --bento-gap.

App shell

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.

Components

Native elements first. No JavaScript unless the entry says so.

Button

Works on <button> and <a>.

HookValuesNotes
data-variantprimary | ghost | dangerOmit for secondary
data-sizesm | lg32 / 56px tall (default 40px)
data-shapeiconSquare; needs aria-label
disabled / aria-disabledOn 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

Icon

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.

Card

A raised container. The border is transparent but real, so forced-color modes still draw its edge.

Card title

Updated today

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.

Alert

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.

Badge

Small inline status label. Neutral without data-status.

Paid Pending Failed Draft Neutral

Field & input

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.

Checkbox & radio

Native controls, sized to 20px and tinted with the brand.

Notifications
Billing

Group with fieldset.choice-group + legend. data-inline lays the options out in a row.

Switch

A checkbox with role="switch". The thumb position shows state, not color alone.

Tokens: --switch-w, --switch-h.

Segmented control

Radios styled as joined buttons. Arrow keys work natively.

View

Table

Plain tables are styled already; .table adds options. Wide tables scroll inside their own region.

OrderStatusTotal
#1042Paid$128.00
#1041Pending$34.50
HookEffect
data-density="compact"28px rows instead of 40px
data-striped, data-hoverZebra rows, row hover
data-sticky-headerHeader 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.

Tabs needs JS

WAI-ARIA tabs. CSS styles the state; a few lines of JS handle selection and arrow keys.

Overview panel

Pagination

40px targets. Mark the current page with aria-current.

An unavailable link has no href, plus role="link" and aria-disabled="true".

Disclosure

Native <details>. The same name makes an exclusive accordion without JS.

What is φ-css?
A layered CSS design system.
Does it need JavaScript?
No, except where an entry says so.

Dialog & drawer

Native <dialog>: top layer, focus trap, Esc and focus return are built in.

Delete project?

This cannot be undone.

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.

Toast needs JS

Transient notification in a live region.

  • If toasts close on their own, pause the timer while hovered or focused (WCAG 2.2.1).
  • Anything the user must act on belongs in a dialog.
  • A modal dialog covers toasts. Put the region in the top layer with popover="manual" if they must float above.

Hero

Eyebrow → display headline → lede → call to action, next to a golden frame.

New in 2.0

Ship calmer UI

One or two sentences that say what it is and why it matters.

Image · 1.618 : 1

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.

Chart

Styles for your SVG. Each series is encoded by color, dash, marker shape and fill texture, and labelled directly.

Plans58% Devices27% Support15%
HookEffect
[data-series="1…6"]Sets color, dash pattern and fill texture for everything inside
.chart__lineSeries stroke with its dash pattern
.chart__bar, .chart__areaTextured fill (url(#phi-fill-N))
<use class="chart__marker" href="#phi-marker-N">Series marker shape
.chart__label, .chart__tickDirect label (main text color) and axis text
.chart__grid, .chart__axisGrid 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.

Reference

Utilities

Top layer: each one wins over any component.

ClassEffect
.hidden, [hidden]display: none, even on components
.visually-hiddenHidden on screen, still read by screen readers
.text-muted, .text-center, .text-balanceText color and alignment
.measureMax width of a readable line (66ch)
.step--1 … .step-6Font size with its matching line height
.surface, .surface-sunkenBackground color
.debug-baselineOverlay the 4px / 8px grid
Muted helper text Step 2

.visually-hidden text is an alternative to aria-label for icon-only buttons. .hidden removes an element for everyone, screen readers included.

Accessibility checklist

What φ-css handles, and what your markup still has to do.

Handled for you

  • Contrast: AA in standard mode, AAA in high-contrast mode, for every brand color.
  • Visible focus ring on every interactive element.
  • Status and selection never rely on color alone (shape, weight, position).
  • Forced colors, reduced motion and right-to-left text.

Your job

  • Set lang on <html> and add a skip link.
  • Give every input a <label>, every icon-only button an aria-label, and every chart a text summary.
  • Set state with ARIA: aria-current, aria-selected, aria-invalid, aria-describedby.
  • Start status messages with a word ("Error:", "Saved") so meaning does not depend on the icon.
  • Make scroll regions focusable: role="region", aria-label, tabindex="0".

Scripts & contract

For contributors. Every rule below fails the build.

CommandDoes
npm run checkLint + contrast checks
npm run buildCheck, then write dist/phi.css
npm run genRegenerate primitive tokens and brand formulas
npm run devServe /demo/ and /docs/ on port 4173

Lint rules

  1. No !important.
  2. No raw color values outside src/tokens/.
  3. No primitive tokens outside src/tokens/.
  4. Every var(--x) must be defined, or have a fallback.
  5. src/layout/: layout properties only, logical properties only, no color or type tokens.
  6. No state classes (.is-active, .selected…); use ARIA and native state.

Contrast checks

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).