Skip to content

START

Theming

A theme is a block of variable overrides. No rebuild, no regenerated utilities, and it works on a subtree as well as on a whole document.

THE FAST PATH

Override the roles that carry your brand. No component states a colour of its own, so the rest follows.

/* your-site.css β€” after the library import */
:root {
  --ds-ground: #0f0f14;
  --ds-cream:  #f3c969;
  --ds-accent: #8b7fd4;
  --ds-rule:   #262633;
}
A NAMED THEME

Give it a name when you want more than one on a page, or a switch between them at runtime.

[data-theme="acme"] {
  --ds-ground: #101820;
  --ds-accent: #f2a900;
  /* …the rest of the 24 */
}
<section data-theme="acme">
  <!-- every component in here follows the acme values -->
</section>

The overview page has two blocks side by side doing exactly this, with identical classes on both.

THE TWO SHIPPED THEMES

portfolio-dark is the default: it is on :root, so importing the package and writing no data-theme gets you it. portfolio-light is the same design on paper, and it is opt-in.

<html data-theme="portfolio-dark">   <!-- or nothing at all -->
<html data-theme="portfolio-light">

<!-- a subtree, on either -->
<section data-theme="portfolio-light"> … </section>

The light theme states colour and nothing else. The scale, the radii, the rhythm and the durations are decisions about shape rather than about light, so they live once in the dark file. A second theme is 24 roles plus the few colours derived from them.

NOTHING HERE READS prefers-color-scheme

A theme is a choice somebody makes, not a preference the browser reports. Neither shipped theme carries a media query, so a site flips only when it is told to. If you want the browser's answer, three lines of your own will do it.

A theme does state --ds-color-scheme, which base.css applies to the document. It covers what no role can reach: form control internals, the autofill wash, a native select's dropdown, and the scrollbar anywhere scrollbar.css was not asked for.

A DERIVED COLOUR MUST BE RESTATED

The veil over a modal is color-mix() of the ground, so it follows a retheme β€” but a custom property is substituted at the element it is declared on. Leave --ds-veil out of a second theme and it resolves against the root's ground, so a light subtree of a dark page wears a black veil. check-css refuses a theme that is missing one.

HOW IT WORKS

One level of indirection, and it is the whole mechanism.

/* the library's theme.css β€” the vocabulary */
@theme inline {
  --color-ground: var(--ds-ground);
}

/* a theme file β€” the values */
:root { --ds-ground: #111a21; }

/* what the build generates */
.bg-ground { background-color: var(--ds-ground); }

The utilities are generated once, at build time. Each one resolves to a --ds-* variable rather than a baked colour, so a theme can change them afterwards and a nested element can change them again.

IF YOU FORK THE THEME LAYER, KEEP inline

Plain @theme emits --color-ground: var(--ds-ground) onto :root, where it resolves once and then inherits the resolved colour downwards β€” so a data-theme on a nested element changes the variable and nothing happens. inline puts the lookup in the utility itself, at the element that draws.

check-theme reads the computed colours off two identically-classed blocks and fails if they match, so dropping inline turns it red straight away.

THE CASCADE

Every stylesheet lives in an ordered sub-layer, and a later layer wins regardless of specificity. The order runs from most contextual to most self-contained, so an atom's own stylesheet is the last word about that atom.

/* src/index.css β€” declared once, before anything is imported */
@layer components.ds-primitives,
       components.ds-sections,
       components.ds-modules,
       components.ds-components;

This is what stops a reasonable nav a { color } at (0,1,1) from dimming a .btn-primary label at (0,1,0) that happens to sit in the nav. The layer order makes that mistake survivable rather than fatal.

The consequence is deliberate: a section or module cannot restyle a component. If a button needs to look different somewhere, it wants a variant β€” .btn-on-cream β€” or a different token.

AND A UTILITY STILL BEATS ALL OF IT

All four sub-layers are nested inside the components layer, so the library keeps its slot between base and utilities. Declaring them at the top level instead would have put them after utilities and quietly broken every bg-* a consumer writes.

COMPONENT+ text-ink-faint
FONTS ARE YOURS

No font files ship with the library. It names two families β€” one for structure, one for reading β€” and loads neither. A site that loads nothing still renders; the stacks fall back to ui-monospace and system-ui.

This page does both: its headings are self-hosted and its body copy comes off a CDN.

/* self-hosted β€” the only option for a face no CDN carries */
@font-face {
  font-family: 'Commit Mono';
  src: url('/fonts/CommitMono-400-Regular.woff2') format('woff2');
  font-weight: 400;
  font-display: swap;
}

:root {
  --ds-font-display: 'Commit Mono', ui-monospace, monospace;
}
<!-- or a CDN link, which is what most sites will do -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet">
:root {
  --ds-font-body: 'Instrument Sans', system-ui, sans-serif;
}

Nothing constrains the pairing. The shipped theme sets structure in a monospace; that is the theme's decision, not the system's.

/* nothing says the display family has to be monospaced */
:root {
  --ds-font-display: 'Playfair Display', Georgia, serif;
  --ds-font-body:    'Inter', system-ui, sans-serif;
}

ONE THING THE SCALE ASSUMES

Sizes are tuned to a face of roughly average width. A very wide or very narrow display family may want --ds-text-* nudged with it β€” they are variables too, and the same override block is the place to do it.

WHAT A THEME OWES

A partial override is fine β€” anything you leave alone keeps its shipped value. A theme meant to stand on its own owes all of these:

THE 24 COLOUR ROLESsee Tokens
THE PLACEHOLDER PAIRSa tint and its caption ink, together
THE TWO FAMILIESdisplay and body β€” and the files to serve them
THE FOCUS RINGderived from the accent, so it usually follows
THE CORNERStwo knobs β€” see Shape
THE DEPTHone dial, flat until you turn it β€” see Relief

A theme owns the shape of a site, its rhythm and its relief as well as its palette β€” the radii, the spacing and the depth sit in the same file as the colours.

Relief is the one that is split. The shape of a shadow β€” how far it falls, how soft, how the rungs are spaced β€” is the same decision on paper and on ink, so it lives once. What a shadow is made of, and how strong, depends on the ground you are standing on, so that lives in each theme file. On a dark ground depth is light on a top edge; on paper it is the shadow underneath.

THERE IS A PAGE THAT DOES ALL OF THIS FOR YOU

Everything above, on knobs. The Theme builder puts every colour role behind a picker and the corners and the relief on sliders, and applies them as you turn them. Three themes ship with it, it keeps yours in your browser, and the block it copies out is the shape above.

ONE THING NOT TO DO

Do not override a component's rule to change a colour. If a button is the wrong shade the answer is a different --ds-cream, not a .btn-primary of your own. A colour stated at a call site cannot be rethemed by the next site, and you will have started a fork.