CSS is Awesome

Authoring a theme

Since v0.8, a theme is a single SCSS source at scss/themes/<name>.scss that compiles to one CSS file at public/themes/<name>/theme.css. Both light and dark modes live inside the single file via native light-dark(). The validator guarantees a clean one-file swap.

Overview

Quickstart — a new theme

// scss/themes/midnight.scss
@use '../mixins' as m;

@include m.theme('midnight') {
  /* Surfaces — light-dark() handles both modes */
  --background-default: light-dark(#f5f5f7, #0a0a0e);
  --surface-default:    light-dark(#ffffff, #14141a);
  --text-primary:       light-dark(#0a0a0e, #f5f5f7);
  --text-secondary:     light-dark(#54545e, #b5b5bf);

  /* Primary accent + auto-derived states via color-mix */
  --action-primary-default: light-dark(#3A5FCD, #60a5fa);
  @include m.states(action-primary);

  /* Typography, radius, motion — identical across modes (no light-dark needed) */
  --font-sans: system-ui, sans-serif;
  --r-md: 6px;
  --duration-fast: 120ms;
}
# build + validate
npm run build:css:themes
node scripts/theme-validator.js public/themes/midnight/theme.css

The theme() signature

@mixin theme($name, $scheme: light dark, $standalone: true)

Three shapes inside a single theme file:

The token contract

The machine-readable source of truth is scripts/theme-contract.json. It lists every CSS custom property a theme must declare. The validator reads this file — if you add a token to the base library, you add it to the contract, and every theme then has to declare it.

The tokens fall into these categories:

127 tokens are required; 36 more are optional, 163 in total. The optional set is the per-component radius and shadow overrides, the logo hooks, the named durations, the touch-target minimum, and the t-shirt spacing aliases.

See /docs/tokens for the full gallery with live swatches and current values for each shipped theme.

File structure

A compiled theme file is a font @import (optional) plus a single :root, :root[data-theme="name"] block that sets every required token. Preserve the commented section headers — they make the file scannable and keep the contract visually grouped.

/* ============================================================
   THEME — My Brand
   ============================================================ */

@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300..700&display=swap');

:root, :root[data-theme="my-brand"] {
  /* Surfaces */
  --paper:        #FFFFFF;
  --paper-raised: #F7F7F5;
  --paper-sunk:   #EEEEEA;
  --paper-glass:  rgba(255,255,255,0.80);

  /* Ink */
  --ink:       #0A0A0A;
  --ink-soft:  #4A4A4A;
  --ink-faint: #8A8A8A;

  /* Lines, Primary, Seal, Accent, Code, Type, */
  /* Radius, Shadow, Blur, Glow, Motion... */

  /* Semantic aliases — bridge to library names */
  --surface-default: var(--paper);
  --surface-raised:  var(--paper-raised);
  --text-primary:    var(--ink);
  --border-default:  var(--hair-soft);
}

In the shipped consolidated file the body is identical, minus the bare :root — inside a bundle every theme would match :root at once and the last one would win, so the attribute is the only thing telling them apart. You do not write this by hand: npm run build:css:themes regenerates public/theme.css from every built theme and strips the bare :root for you.

:root[data-theme="my-brand"] {
  /* same 127 required token declarations */
  --paper: #FFFFFF;
  /* ... */
}

Step by step

Edit the SCSS source, never the built CSS. public/themes/*/theme.css is a build artifact, and npm run check:theme-drift fails CI if it stops matching its source.

  1. Copy scss/themes/press-light.scss (or any shipped theme) as a starting point. Press is a good editorial baseline; Cupertino is a good rounded/soft baseline; Terminal is a good dark-mode baseline.
  2. Rename the file to scss/themes/<your-theme>.scss and change the name you pass to m.theme('…') to match. npm run build:css:themes compiles it to public/themes/<your-theme>/theme.css.
  3. Change every token value — leave every token name. Work category by category: start with surfaces (--paper, --paper-raised, --paper-sunk), then ink (--ink, --ink-soft, --ink-faint), then the rest of the palette, then type, then radii and shadows.
  4. Keep the semantic aliases intact: --surface-default: var(--paper), --text-primary: var(--ink), etc. These bridge library names to your native palette. Change the right-hand side only if your mood genuinely requires a different mapping (e.g. you want --surface-default to resolve to --paper-raised).
  5. Build, then run the validator against the compiled file:
    $ npm run build:css:themes
    $ node scripts/theme-validator.js public/themes/<your-theme>/theme.css
    It prints every missing token and every contrast failure. Fix them in the SCSS source, rebuild, and re-run until the output reads OK.
  6. Once the single file validates, register the theme in the picker. Add an entry (id + label) to the THEMES array in src/components/ThemePicker/ThemePicker.tsx, and — if you want a tile in the gallery — to the THEMES array in src/app/themes/gallery/page.tsx.
  7. Nothing else to do for the bundle: npm run build:css:themes regenerates public/theme.css from every built theme. The per-theme file is kept for standalone deploys; the bundle powers the docs site picker.

The validator

scripts/theme-validator.js is zero-dependency Node. It auto-detects per-file vs consolidated input and reports missing tokens with a non-zero exit code on failure.

Exit codes:

Wire npm run validate-themes into your pre-commit or CI step and authorship becomes a closed loop: if it passes, it ships.

Design guidance

Testing

Shipping

For a standalone deploy, drop your theme file in your own public/themes/<your-theme>/theme.css and link it from your HTML before cia.css. No registration and no data-theme attribute required — the file emits a bare :root alongside its own attribute selector, and the library’s defaults sit at zero specificity under :where(:root), so your theme wins regardless of load order.

To contribute your theme upstream, open a PR using the Theme Submission template at .github/ISSUE_TEMPLATE/theme_submission.yml. See the full checklist in CONTRIBUTING-THEMES.md at the repo root — it covers validator output, screenshots per page, contrast notes and the picker registration diff.

Theme