Theme Authoring

Fork the Interlace brand without forking the DS. Everything below is checked by a test rather than by review — a theme that is incomplete, misspelled or unreadable fails theme-contract-lock before it reaches a page.

The switcher in this page's header is the DS's own ThemeSwitcher, driving the two axes described here. Try it: everything on this site, including the live component previews, repaints.

Two ways to re-brand, and they are not the same

Both are supported and they answer different questions. Pick the first if your app has ONE brand; pick the second if a reader has to be able to switch.

 Override the layerRegister a theme
Lives inyour app's global.csspackages/ui/styles/themes/<name>.css
Selector:root / .dark[data-theme='<name>']
Switchable at runtimeno — it IS your brandyes
Checked by the locknoyes — every token, every scheme
Ships to other consumersnoyes, via the registry

1. Path A — override @layer interlace.brand

Import the DS baseline, then declare the brand layer after it. Source order does not matter — the layer wins by cascade rule, not by specificity or position.

/* consumer's global.css */
@import "tailwindcss";
@import "@interlace/ui/styles/index.css";

@layer interlace.brand {
  :root {
    --interlace-primary: oklch(0.55 0.22 264);          /* your blue */
    --interlace-primary-foreground: #ffffff;
    --interlace-background: #ffffff;
    --interlace-foreground: #0a0a0f;
    --interlace-muted-foreground: #4a4458;              /* ≥4.5:1 on background */
    /* … the rest of the manifest */
  }

  .dark {
    --interlace-primary: oklch(0.78 0.18 264);
    --interlace-background: #0a0a0f;
    --interlace-foreground: #ededf2;
    --interlace-muted-foreground: #c4c0d4;
    /* … */
  }
}

The Interlace defaults were Tailwind violet until July 2026 and were repointed to burnt orange + green entirely inside this layer, with zero component edits. That is the whole claim, and it is why the layer — not the components — is the override surface. Full layer model: CSS contract.

2. Path B — first, the two axes

A colour SCHEME and a THEME are different questions and must not share a selector. Until Phase 8 they did: [data-theme='dark'] meant dark mode, which spent the one obvious attribute for “which brand” on “which scheme” and left a second brand nowhere to go but a fork of the file.

SelectorMeans
:rootInterlace · light — the default
.dark, [data-scheme='dark']Interlace · dark
[data-theme='X']theme X · light
[data-theme='X'].darktheme X · dark
[data-theme='X'] .darktheme X · dark, on a SUBTREE

.dark stays load-bearing on purpose: it is the shadcn / next-themes convention that every consumer and the Storybook decorator already write. The theme axis is purely additive.

Do not skip the last row

The bare .dark block in interlace-theme.cssis unscoped, so what it really means is “Interlace dark” — it re-declares every --interlace-* literal on whatever element carries it. And [data-theme='X'].dark needs both on the SAME element. So a <div class="dark"> anywhere inside your themed page silently repaints that subtree in the DEFAULT brand. It reads as “the theme did not apply”. Declare the descendant forms too; the lock checks that you did.

3. Define all 55 tokens — twice

A theme is a data file, not a pile of CSS: it supplies --interlace-* literals and nothing else. The semantic aliases (--background, --primary, the @theme inline registration) are owned by the DS and never change — that is the entire point of the cascade.

Every token below must appear in BOTH the light block and the dark block. Not because it is tidy: a missing token does not throw and does not warn. It resolves from whatever rule of lower specificity last matched — the previous theme, or this theme's other scheme — so the page renders 54surfaces in your brand and one in someone else's, and nobody files a bug because nobody can name what is wrong.

The list is not maintained by hand: it is derived from the :root block of interlace-theme.css, and the lock re-derives it on every run and fails if the two disagree.

Brand the identity pair and its states (8)

  • --interlace-primary
  • --interlace-primary-hover
  • --interlace-primary-active
  • --interlace-primary-foreground
  • --interlace-primary-subtle
  • --interlace-primary-subtle-foreground
  • --interlace-brand-mark-bar-o
  • --interlace-brand-mark-bar-g

Surfaces & text every plane a component can paint on, and what is legible on it (15)

  • --interlace-background
  • --interlace-foreground
  • --interlace-card
  • --interlace-card-foreground
  • --interlace-popover
  • --interlace-popover-foreground
  • --interlace-muted
  • --interlace-muted-foreground
  • --interlace-border
  • --interlace-input
  • --interlace-ring
  • --interlace-accent
  • --interlace-accent-foreground
  • --interlace-secondary
  • --interlace-secondary-foreground

Status the semantic tones — each with the foreground that sits on it (10)

  • --interlace-destructive
  • --interlace-destructive-foreground
  • --interlace-success
  • --interlace-success-foreground
  • --interlace-warning
  • --interlace-warning-foreground
  • --interlace-info
  • --interlace-info-foreground
  • --interlace-caution
  • --interlace-caution-foreground

Overlay copy over imagery (2)

  • --interlace-scrim
  • --interlace-scrim-foreground

Hero the cosmic hero surface (6)

  • --interlace-hero-star
  • --interlace-hero-trail
  • --interlace-hero-meteor
  • --interlace-hero-surface
  • --interlace-hero-surface-deep
  • --interlace-hero-foreground

Window chrome the traffic-light dots in code/window ornaments (3)

  • --interlace-window-control-close
  • --interlace-window-control-minimize
  • --interlace-window-control-zoom

Data visualisation series colours plus the grid, axis and edge strokes (8)

  • --interlace-chart-1
  • --interlace-chart-2
  • --interlace-chart-3
  • --interlace-chart-4
  • --interlace-chart-5
  • --interlace-viz-grid
  • --interlace-viz-axis
  • --interlace-viz-edge

Radius structural, but a theme may restate it (3)

  • --interlace-radius-sm
  • --interlace-radius-md
  • --interlace-radius-lg

4. Wire it up — three edits, all of them checked

/* 1. packages/ui/styles/themes/<name>.css */
@layer interlace.brand {
  [data-theme='<name>'] { /* … every token, light … */ }

  [data-theme='<name>'].dark,
  [data-theme='<name>'][data-scheme='dark'],
  [data-theme='<name>'] .dark,
  [data-theme='<name>'] [data-scheme='dark'] { /* … every token, dark … */ }
}
/* 2. packages/ui/styles/index.css — AFTER interlace-theme.css.
      Order is load-bearing: [data-theme='X'] and .dark are BOTH (0,1,0),
      so the tie is broken by source order. */
@import "./interlace-theme.css";
@import "./themes/<name>.css";
// 3. packages/ui/src/lib/theme-tokens.ts
export const THEMES = [
  /* … */
  { name: '<name>', label: '<Label>', description: '…', default: false },
] as const;

The registry is what the switcher renders and what the no-flash bootstrap validates against, so this third edit is not bookkeeping: without it, the theme exists in CSS and is unreachable — and with only it, the switcher offers a theme no stylesheet defines. The lock asserts the two lists agree.

Currently registered: interlace (default), harbor.

5. Run the lock — what it refuses

cd packages/ui && npx vitest run __tests__/theme-contract-lock.test.ts

Six ways it goes red, each of them a defect that is otherwise completely silent:

  1. A missing token.It inherits the previous theme's value and ships a two-brand page.
  2. An extra token. A typo is silent for exactly the same reason — --interlace-muted-forground is a perfectly valid custom property that nothing reads, and the real token keeps its inherited value.
  3. A value it cannot parse. Hex, oklch(), a var() alias to another brand token, or a rem length. A token that is present but unreadable passes a completeness check and fails the user.
  4. Text below 4.5:1, on any surface it actually paints. Over 20 pairs per theme × scheme — body copy, copy in a card, in a popover, on muted, every filled status chip, and text-primary on bg-primary/10, which is where a primary colour usually breaks.
  5. Non-text UI below 3:1. The focus ring (SC 2.4.13) and control borders and chart axes (SC 1.4.11), measured on the page AND on a card.
  6. A selector that moved. The lock matches the stylesheet's selectors exactly, including the subtree forms. If the matrix changes and the table in the test does not, it fails — rather than parsing an empty block and making every assertion above vacuous.

6. Measure the contrast — do not look at it

“It looks fine on my screen” is how #eae7e2 shipped as a form-control border at 1.23:1. Three layers measure instead, and they overlap on purpose:

  • The palette. theme-contract-lockrecomputes every pair from the hex in your theme file on every run. The ratios written in a theme's header comment are a reader's convenience; the test is the authority, and it fails if the comments become fiction.
  • The composites. composite-contrast-lock walks component source for tinted pairs (bg-primary/10 + text-primary) that no palette check can see.
  • The rendered page. The storybook (a11y) CI gate runs axe over every story — and then re-runs the colour rules once per registered theme, at the scheme the story is in. The theme list there is discovered from the stylesheet that loaded, so your theme is swept the moment it is imported, with no edit to the gate.

Axe alone is not a contrast gate

It scores TEXT against its background and never a control's BORDER against the surface behind it. Three WCAG 2.2 failures — --input at 1.23:1, the focus ring at 2.57:1, the slider rail at 1.07:1 — sat behind a green axe run in both schemes. SC 1.4.11 and SC 2.4.13 are held by token maths, which is why the lock exists and why it is not satisfied by a passing browser scan.

To see resolved values rather than compute them, the semantics catalogue lists every semantic token with its light and dark value.

7. Ship it in the UI — and mind the flash

A theme applied after hydration is a repaint on every page load: the document paints the default, React mounts, an effect reads localStorage, and only then does the page become what the user chose. On a slow connection that is half a second of the wrong brand. No amount of correctness in the hook fixes it — by the time any React code runs, the wrong paint has already happened.

// app/layout.tsx — server component, no 'use client'
import { THEME_SCRIPT } from '@interlace/ui/theme-script';

<html lang="en" suppressHydrationWarning>
  <head>
    <script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
  </head>
// anywhere in your chrome
import { ThemeSwitcher } from '@interlace/ui/theme-switcher';

<ThemeSwitcher size="sm" align="end" />

suppressHydrationWarning on <html> is required — the script deliberately mutates the element React is about to hydrate, which is the same thing next-themes asks for. The script is derived from the registry, so a theme added in step 4 needs no edit here.

Embedding previews in an iframe? The frame is a separate document: nothing about the page's palette crosses into it. Pass the theme explicitly — this site does it with Storybook's ?globals=interlaceTheme:<name> — or your previews will keep showing the default brand under a re-themed page.

The one asymmetry: Interlace has no [data-theme]

interlace is not a theme file. It IS :root, and it is written as the ABSENCE of the attribute — there is no [data-theme='interlace'] selector anywhere. Choosing it removes the attribute rather than setting it, which keeps “no preference” and “chose the default” from becoming indistinguishable in the DOM.

The visible consequence is in the switcher: the obvious flourish — a dot per theme painted in that theme's own primary — cannot be done honestly. The dot would need [data-theme='X'] on itself, and for the default there is no such selector, so its swatch would paint in whatever theme is currently active. A dot that lies about which brand it represents is worse than no dot, so ThemeSwitcher ships check marks and the check carries the state.

Adding [data-theme='interlace'] as a :rootalias would fix it. It is a CONTRACT change, not a component change: the selector matrix, the manifest's provenance rule (the manifest is derived from the :rootblock), the lock's selector table and the stylesheet header all move together, or the lock goes red — by design.

What you cannot safely override

  • @layer interlace.preflight — the focus-ring + min-viewport baseline. Overriding it costs WCAG 2.2 SC 2.4.13.
  • @layer interlace.bridge — fumadocs ↔ shadcn translation. Overriding it breaks cross-framework primitive resolution.
  • @layer interlace.foundation— type scale + spacing scale + radius + container widths. These are structural; if you really need a different spacing scale you're forking the DS, not theming it.
  • @layer interlace.semantics — a theme file that declares --background directly has bypassed the alias graph, and the next token added to the graph will not reach it. The lock refuses this outright.

Token namespace & collisions

Tailwind v4's @theme does not scope anything — a key declared there is a claim on a global namespace. This baseline claims 116 of them. Where your app claims the same key, one registration wins silently: there is no duplicate-key diagnostic, no build warning, and no runtime error. The utility simply compiles against the other value.

Read this first

The colour block is `@theme inline`, so Tailwind substitutes the value into the utility and never emits `--color-*` at runtime. `bg-accent` compiles to `var(--accent)`. Overriding `--color-accent` in your own `:root` therefore has NO effect — override `--accent` (semantics) or `--interlace-accent` (brand) instead.

The traps — where the name means something other than you think

KeyYou expectYou getOverride instead
--color-accentthe brand highlight colourshadcn’s hover/selected SURFACE — #fef4ed light, #3d1a08 dark. 8:1 lighter than the brand orange beside it. Brand-coloured elements turn near-white. Fails at paint time; no build, type or lint error.--accent (semantics layer) or --interlace-accent (brand layer)
--color-secondarya second brand coloura light grey surface — #f5f3f0 light. Secondary buttons and badges lose their colour entirely.--secondary or --interlace-secondary
--color-mutedde-emphasised TEXTa background tint — #faf7f4 light. Near-white text on white. `--color-muted-foreground` is the text colour.--muted / --muted-foreground
--color-bordera visible divider#eae7e2 — a deliberate 1.23:1 hairline. It is an intentional AA exemption here, so no contrast gate will flag it. Dividers and card outlines effectively disappear.--border or --interlace-border
--breakpoint-sm … --breakpoint-xlto be additivea REPLACEMENT of Tailwind’s default ladder. `sm` moves 640px → 480px, and `2xl` stops existing. Every `2xl:` utility in the consumer’s existing code silently stops compiling.redeclare the full ladder — including `--breakpoint-2xl` — in your own @theme
--spacing-xs … --spacing-2xlto affect padding and margin onlythe `spacing` namespace also feeds `max-w-*` / `w-*` / `h-*` in Tailwind v4. `max-w-sm|md|lg|xl|2xl` resolve to 16/24/40/64/96px instead of 24/28/32/36/42rem — a 20× narrower container, with no error.rename your own scale, or redeclare --spacing-* after the import
--radius-sm / --radius-md / --radius-lgto be additiveoverrides Tailwind’s defaults — 2/6/8px becomes 8/12/16px. Every `rounded-sm|md|lg` in the consumer’s app gets rounder.redeclare --radius-* after the import
--font-sansto be additivea full replacement of Tailwind’s default sans stack. App-wide font change.redeclare --font-sans after the import

Migrating an app that already themes

  1. Diff your own @theme block against the claimed list below. Every name in both is a collision.
  2. For each collision, decide who owns the name. If it is you, redeclare it after the DS import — see the recipe below. If it is the DS, rename yours.
  3. Do not fix a --color-* collision by redeclaring --color-*. It is @theme inline; nothing reads that variable at runtime. Override the semantic (--accent) or the brand token (--interlace-accent).
  4. Check --breakpoint-2xl specifically. The DS ladder has no 2xl, so every 2xl: utility you already ship stops compiling — the one collision on this page that fails loudly enough to notice.
  5. Grep for max-w-sm|md|lg|xl|2xl. The DS --spacing-* scale feeds those, and they become up to 20× narrower.
@import "tailwindcss";
@import "@interlace/ui/styles/index.css";

/* Declared AFTER the import, into the DS's own layer: wins regardless of
   source order or specificity, and survives a DS upgrade. */
@layer interlace.brand {
  :root { --interlace-accent: #0b5fff; }
}

Every key this baseline claims

Parsed from the stylesheets the theme item ships, so it cannot drift from what is actually published.

--color-* (54)

--color-background · --color-foreground · --color-card · --color-card-foreground · --color-popover · --color-popover-foreground · --color-primary · --color-primary-foreground · --color-secondary · --color-secondary-foreground · --color-muted · --color-muted-foreground · --color-accent · --color-accent-foreground · --color-border · --color-input · --color-ring · --color-destructive · --color-destructive-foreground · --color-success · --color-success-foreground · --color-warning · --color-warning-foreground · --color-info · --color-info-foreground · --color-caution · --color-caution-foreground · --color-scrim · --color-scrim-foreground · --color-hero-surface · --color-hero-surface-deep · --color-hero-foreground · --color-window-control-close · --color-window-control-minimize · --color-window-control-zoom · --color-chart-1 · --color-chart-2 · --color-chart-3 · --color-chart-4 · --color-chart-5 · --color-viz-grid · --color-viz-axis · --color-viz-edge · --color-viz-edge-active · --color-viz-node · --color-viz-node-active · --color-viz-crosshair · --color-viz-positive · --color-viz-negative · --color-viz-neutral · --color-viz-band · --color-viz-annotation-publish · --color-viz-annotation-release · --color-viz-annotation-action

--text-* (24)

--text-h1 · --text-h1--line-height · --text-h2 · --text-h2--line-height · --text-h3 · --text-h3--line-height · --text-h4 · --text-h4--line-height · --text-h5 · --text-h5--line-height · --text-h6 · --text-h6--line-height · --text-body · --text-body--line-height · --text-long · --text-long--line-height · --text-ui · --text-ui--line-height · --text-ui-sm · --text-ui-sm--line-height · --text-code · --text-code--line-height · --text-caption · --text-caption--line-height

--animate-* (12)

--animate-shimmer-slide · --animate-spin-around · --animate-gradient · --animate-marquee · --animate-marquee-vertical · --animate-first · --animate-second · --animate-third · --animate-fourth · --animate-fifth · --animate-accordion-down · --animate-accordion-up

--spacing-* (6)

--spacing-xs · --spacing-sm · --spacing-md · --spacing-lg · --spacing-xl · --spacing-2xl

--leading-* (4)

--leading-body · --leading-long · --leading-ui · --leading-heading

--container-* (4)

--container-prose · --container-content · --container-wide · --container-float

--breakpoint-* (4)

--breakpoint-sm · --breakpoint-md · --breakpoint-lg · --breakpoint-xl

--font-* (3)

--font-body · --font-sans · --font-mono

--radius-* (3)

--radius-sm · --radius-md · --radius-lg

--tracking-* (2)

--tracking-display · --tracking-heading

54 of these are colours, and all of them are declared @theme inline.