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
- Author SCSS source:
scss/themes/<name>.scss. Wrap everything in@include cia.theme('name') {...}— the mixin emits:root, :root[data-theme="name"]plus thecolor-schemedeclaration. The bare:rootis what makes a dropped-in file work with no markup change. - Build to CSS:
npm run build:css:themescompiles every source inscss/themes/topublic/themes/<name>/theme.css. - Validate:
node scripts/theme-validator.js --all. Every theme must declare every required token inscripts/theme-contract.json. WCAG 2.2 AA contrast is also checked.
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)
$name— thedata-themevalue.$scheme— thecolor-schemevalue. Passlightordarkfor a single-mode theme.$standalone—true(the default) emits:root, :root[data-theme="name"]so the file works on its own. Passfalsefor the multi-theme bundle, where every theme shares one file and a bare:rootwould make them collide — there the attribute is the only thing telling them apart. You never hand-write a bundle block:scripts/build-theme-bundle.mjsregeneratespublic/theme.cssfrom every built theme and strips the bare:rootas it goes.
Three shapes inside a single theme file:
- Mode-stable — declare
color-scheme: light(or dark) explicitly and skiplight-dark(). Used by Sketchbook (light-only brand) and Terminal (dark-only sacred). - Symmetric (Pattern B) —
light-dark()per color token; fonts/radii/motion identical across modes. Used by Boilerplate, Prism, Cupertino, Graphite, Press, Sketchbook (both modes). - Asymmetric (Pattern C) — one nested
@media (prefers-color-scheme: dark)block inside the theme for non-color overrides (different blur, font, or radius per mode). Used by Glass.light-dark()is color-only per spec; non-color values need the nested block.
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:
- Surfaces —
--paper,--paper-raised,--paper-sunk,--paper-glass. The page, card and elevated backgrounds. - Ink —
--ink,--ink-soft,--ink-faint,--graphite,--muted. Body text and its soft-to-faint ramp. - Lines —
--guide,--guide-soft,--hair,--hair-soft. Rules, dividers and borders. - Primary —
--ai,--ai-ink,--ai-wash. The main interactive accent (links, primary buttons). - Seal —
--shu,--shu-wash. The emphasis accent (badges, important callouts). - Accent —
--ochre,--ochre-wash. Marginalia, pull-quotes, tertiary accent. - Code —
--code-bg,--code-ink,--code-muted,--code-accent,--code-green,--code-blue. Syntax surface and its semantic tokens. - Type —
--font-display,--font-serif,--font-sans,--font-mono,--font-script,--font-primary, plus size/weight/line-height tokens. - Radius —
--r-sm/md/lg(library short scale) plus--radius-sm/md/lg/xl/full(semantic scale). The per-component overrides —--btn-radius,--card-radius,--input-radius,--modal-radius,--badge-radius,--tag-radius— are optional. Leave them out and each cascades from the generic radii above. - Shadow —
--shadow-smthrough--shadow-2xl. - Blur —
--blur-sm/md/lg. Glass and backdrop filters. - Glow —
--glow-sm/md/lg. Focus rings and hover auras. - Motion —
--duration-fast/normal/slow,--ease. - Semantic aliases —
--surface-default,--text-primary,--border-default,--action-primary-*,--interactive-hover,--background-*, etc. These bridge library names to the native palette. - Feedback / status —
--success-*,--warning-*,--error-*,--info-*,--feedback-*. Every status colour has adefault,subtleandtextvariant. - Spacing — the numbered scale
--space-0through--space-9. Required, and owned by the theme: declare it and the whole page re-proportions. The six t-shirt names (--space-2xs/xs/sm/md/lg/xl) are optional — the library emits them asvar()references (--space-md: var(--space-4)), so they follow the numbered scale automatically. - Layering — the
--z-*scale (--z-dropdown,--z-modal, etc.).
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.
- 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. - Rename the file to
scss/themes/<your-theme>.scssand change the name you pass tom.theme('…')to match.npm run build:css:themescompiles it topublic/themes/<your-theme>/theme.css. - 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. - 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-defaultto resolve to--paper-raised). - Build, then run the validator against the compiled file:It prints every missing token and every contrast failure. Fix them in the SCSS source, rebuild, and re-run until the output reads
$ npm run build:css:themes $ node scripts/theme-validator.js public/themes/<your-theme>/theme.css
OK. - Once the single file validates, register the theme in the picker. Add an entry (id + label) to the
THEMESarray insrc/components/ThemePicker/ThemePicker.tsx, and — if you want a tile in the gallery — to theTHEMESarray insrc/app/themes/gallery/page.tsx. - Nothing else to do for the bundle:
npm run build:css:themesregeneratespublic/theme.cssfrom 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.
node scripts/theme-validator.js public/themes/my-theme/theme.css— validate one standalone file. You can pass multiple paths.node scripts/theme-validator.js --all— discover and validate every shipped theme (the consolidatedpublic/theme.cssplus everypublic/themes/*/theme.css).node scripts/theme-validator.js --help— print usage.
Exit codes:
0— every validated file / block declares every required token.1— one or more files or blocks are missing tokens.2— usage error (file not found, bad args, bad contract).
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
- Keep
--paperand--inkcontrasting — WCAG AA minimum (4.5:1) for body text, 3:1 for large text. Everything else is built on top of this pair. --aiis the primary accent; make it visually distinct from--shu(emphasis) and--ochre(marginalia). When all three appear on the same page, the reader should immediately know which is the link, which is the badge and which is the pull-quote.- If your theme is dark-mode,
--paperis still your dark surface and--inkis still your light text — the semantic names stay, the values swap. Do not rename tokens. - Status colours (
--success-default,--error-default,--warning-default,--info-default) should hit WCAG AA against--paper. Their-textvariants are for foreground use on-subtlewashes. - Radii, shadows and motion durations define your theme's "voice" as much as colour does. Editorial themes have tight radii (2–4px), near-flat shadows, fast easing. Apple-flavoured themes have generous radii (10–14px), layered shadows, slower easing. Pick a voice and keep it consistent.
Testing
- Run the validator —
node scripts/theme-validator.json your file, then--allto confirm you haven't broken any shipped theme. - Swap the attribute on
<html data-theme="<name>">manually in DevTools and click through every page of the docs site. Every component should re-skin; nothing hard-coded should peek through. If you see a stray colour, the offender is the base library, not the theme — file a bug. - Contrast is checked for you: the validator audits 22 foreground/background pairs per theme — ink on paper, link on paper, every status
-texton its matching-subtle, and the five--code-*tokens on--code-bg— in bothlight-dark()branches, keeping the worse result. Failures fail the build. - Confirm the artifact still matches its source with
npm run check:theme-drift. It rebuilds every theme into a scratch copy and diffs, so an edit made to the CSS instead of the SCSS shows up immediately.
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.