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.
From design tokens (Figma → theme.css)
If your palette, spacing and type scale already live in a design tool, skip the SCSS: cia theme from-tokens turns a design-tokens file into a complete, validated theme.css in the exact shape the shipped themes use. No Sass involved — the CLI, the MCP tool theme_from_tokens and the in-process handler are one function (scripts/tokens-to-theme.cjs, shipped in the package).
# DTCG, Tokens Studio, or a flat --token map — format auto-detected npx cia theme from-tokens tokens.json --name acme --out src/styles/acme.css # a separate dark file → light-dark() values npx cia theme from-tokens light.json --dark dark.json --name acme --json # inherit whatever you did not specify from a different shipped theme npx cia theme from-tokens brand.json --name acme --base press
Input formats
- DTCG v2025.10 (
--format dtcg): nested groups whose leaves carry$valueand optionally$type. Aliases{group.path}resolve recursively; an unresolved alias or a cycle is an error, never a silent drop. Composite values are understood: colour objects ({ hex }or{ colorSpace, components, alpha }), dimension and duration objects ({ value, unit }), shadow objects or arrays, font-family arrays, cubic-bezier arrays.$extensionsis ignored. - Tokens Studio for Figma (
--format tokens-studio): leaves carryvalueandtype. A single set works as-is; a multi-set export (top-level$themes/$metadata) merges the sets in$metadata.tokenSetOrder, later sets winning. Numbers exported as strings ("16") get their unit from the type. Paired top-level groups —color-light+color-dark, orlight+dark— are split into the two modes automatically. - cia-flat (
--format cia-flat):{ "--brand-primary": "#3A5FCD" }, passed straight through.
How a path becomes a token
- An explicit table maps Figma-style names that do not spell the cia token:
typography.font.body→--font-sans,shape.radius.md→--radius-md,effect.shadow.md→--shadow-md,motion.duration.fast→--duration-fast,layer.modal→--z-modal. - Otherwise a leading
color./colors.is stripped, common group names are rewritten (spacing.→space.,border-radius.→radius.,elevation.→shadow.,zIndex.→z.,fontFamilies./font.family.→font.,components.→component.). Per-component overrides are accepted flat or nested, and both land on the same contract token:component.btn-radiusandcomponent.button.radiusboth give--btn-radius, and because the contract names some of these the other way round,component.card-shadowgives--shadow-card, and the segments are joined with-. If that name is in the contract it is used:space.4→--space-4,brand.primary→--brand-primary,text.primary→--text-primary,code.bg→--code-bg. - Anything else is emitted verbatim as
--<joined-path>and listed inreport.unmapped, so your own tokens survive and nothing disappears.
Minimum content
None. Every required contract token the file does not supply is inherited from the base theme (--base, default boilerplate; --base list prints the 24 options) and listed in report.inherited. Optional tokens are never inherited — the library default applies. The validator and the WCAG contrast audit run on the result and are returned as validation; the CLI exits non-zero on a failure unless you pass --allow-a11y-fail. In practice a brand palette plus a text colour is enough to get a passing theme; the more of the contract the file supplies, the less it borrows.
Read the mapping as data
If another tool has to map the same token names the same way (a boilerplate registry, a Figma plugin, an agent), do not copy the rules above — read them. npx cia theme map --json prints the explicit table, the prefix rewrites and the generic rule as one JSON object; npx cia theme map --path color.text.primary shows how a single name resolves and whether the target is required or optional. The MCP tool get_token_map returns the same data, and require('css-is-awesome/scripts/tokens-to-theme.cjs') exposes it in-process as tokenMap() and resolvePath(path).
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. Optional here does not mean unset: the library declares every one of them on:root, so a theme that says nothing still resolves to a real value.--btn-radiusand--input-radiusfall back tovar(--radius-md, 0.25rem),--card-radiustovar(--radius-lg, 0.5rem), and--modal-radiustovar(--radius-xl, 0.75rem). Ask the MCP serverget_tokenfor any token and itslibraryDefaultfield answers the same question without reading source. - 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, plus--space-unit(the density knob, since v1.1 EPIC-05). All required, and owned by the theme: declare them 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; 49 more are optional, 176 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.
Page surfaces — hero and band
A theme reskins components, but the page behind them used to be one flat colour: a landing page and a docs page looked the same underneath. Two optional surfaces fix that — a hero for a front page and a band for a section stripe or a footer. Each has four tokens, all optional.
--page-hero-bg: light-dark(#f2f6fe, #121d36); --page-hero-ink: light-dark(#09090b, #fafafa); --page-hero-image: url("/hero.jpg"); /* gradient or url() you host */ --page-hero-scrim: rgb(0 0 0 / 0.5); /* only paints over an image */ /* --page-band-* takes the same four */
Nothing happens until you ask
Declaring these changes no pixel. A page only takes a surface when you apply one, which is why every shipped theme can define them without touching an existing site. cia never applies a page surface on its own — not on body, not from a layout mixin, not from the reset — and a test compiles every shipped bundle to prove it.
// SCSS — on any element .landing { @include cia.surface(hero); } // …or no SCSS at all, for a whole page <body data-surface="hero">
The attribute form needs one call to cia.surface-attributes() in a global stylesheet. Both paths read the same custom properties, so there is one place to maintain.
Images and the scrim
A theme is a single CSS file, so an image is a url() you host or a gradient, which needs nothing external. Contrast over a photo cannot be measured, so the scrim is the safety net: pass an image to the mixin and it lays 50% black between the picture and your text. A colour-only surface gets no scrim, because a wash over a flat colour would just darken it.
Every shipped theme already carries a derived bg and ink pair — its own page surface nudged a few percent toward its own brand accent. That keeps the contrast relationship the theme already passes, so all 24 land above 10:1. Override either token to design a hero deliberately.
--background-hero is deprecated in favour of --page-hero-bg, which falls back through it, so an old declaration keeps working until contract 2.
Upgrading a theme
You never have to. cia deprecates a token instead of deleting it, so an old declaration keeps resolving until the next major. When you do want to move, one command does it:
# show me what would change npx cia fix-theme src/styles/acme.css # apply it npx cia fix-theme src/styles/acme.css --write
It renames the property and nothing else — your values, comments, ordering and whitespace survive byte-for-byte, so the theme renders exactly as it did. If a block already declares the replacement, that line is left alone and reported: deciding which of two values you meant is your call, not the tool’s. Agents reach the same function as the MCP fix_theme tool, and npx cia analyze points here when it finds a deprecated token in your stylesheets.
The density knob — three levels of power
Every shipped theme derives its 9 spacing steps from one master variable via calc():
--space-unit: 0.125rem; --space-1: calc(var(--space-unit) * 4); /* 0.5rem */ --space-4: calc(var(--space-unit) * 8); /* 1rem */ /* ...through --space-9 */
Nothing about that is locked — there are three levels, and none of them require editing more than what you actually mean to change:
- The unit — override
--space-unitalone and all 9 steps rescale together, proportionally, at runtime. This is what the theme editor's density slider edits. Halve it for a noticeably tighter UI; raise it for an airier one — one edit instead of nine. - A step — override a single
--space-Nwith a literal (e.g.--space-5: 2rem;) to deliberately break the ramp at just that rung. A literal always wins over thecalc()it replaces, so this keeps working exactly as before — nothing about the density knob changes how an individual override behaves. - Raw passthrough — a component that needs a spacing value outside the 9-step scale entirely isn't stuck: call
m.space(0.5rem)(a literal length instead of a scale key) orm.grid(n)(any multiple of cia's 4px geometric grid, deliberately not themed — see the mixin's own doc comment inscss/_mixins.scss). Neither of those touches--space-unitor the numbered scale at all.
Themes get this for free by using the shared mixin instead of hand-writing all 10 values:
@use '../spacing-scale' as ss; @include m.theme('my-theme') { @include ss.space-scale; /* --space-unit + --space-0..9, default unit 0.125rem */ /* or: @include ss.space-scale($unit: 0.15rem); for a theme that ships airier by default */ }
A theme.css you download from the editor is a flattened snapshot — every token, --space-unit included, is exported as a plain literal, same as every other token. To keep the knob live in your own fork, swap the nine --space-N lines back for calc(var(--space-unit) * N), matching the multipliers in scss/_spacing-scale.scss.
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 24 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.