Theming Dovetail

A theme is a CSS file that overrides semantic tokens. That is the whole mechanism. No build step, no component changes, no fork.

The 20-minute version

You have a brand palette and a typeface. Here is the path from those to a working theme.

1. Start from the template

/* tokens/themes/theme-acme.css */
:root {
  /* your overrides here */
}

Link it after styles.css:

<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="tokens/themes/theme-acme.css">

2. Replace the brand ramps, not the semantic roles

The cheapest correct theme changes eleven lines. Override the primitive primary ramp in place, and every semantic role that referenced it follows automatically: the brand fills, brand text, charts, progress, badges and the thinking animation. A second brand hue is the same move on the secondary ramp, --dt-color-secondary-*, which the secondary brand fills, the duotone sweep and the second data-visualisation colour read.

:root {
  --dt-color-primary-050: oklch(0.970 0.015 340);
  --dt-color-primary-100: oklch(0.936 0.035 340);
  /* … through 950 */
}

This is the one case where a theme legitimately writes at Tier 1. You are replacing the raw material, not bypassing the contract.

Generating the ramp. Keep the lightness values from the default ramp and change only chroma and hue. OKLCH lightness is perceptually uniform, so a primary ramp built on the existing lightness steps inherits the contrast behaviour the system was tested against.

3. Decide what colour the controls are

By default the interface is monochrome: buttons, links, selection and focus are ink (--dt-color-neutral-900, inverting to near-white in dark mode), secondary buttons are a soft grey fill, and the brand colour is kept for accents. That is deliberate: a brand hue on every control competes with the content, and ink passes contrast on any brand.

To paint the controls in the brand instead, point each role at its -brand twin. The twins are declared for light and dark, so the choice survives a dark band too. It is what Configure's Buttons and links: Brand writes.

:root, .dark {
  --dt-surface-action: var(--dt-surface-action-brand);
  --dt-surface-action-hover: var(--dt-surface-action-brand-hover);
  --dt-surface-action-active: var(--dt-surface-action-brand-active);
  --dt-text-on-action: var(--dt-text-on-action-brand);
  --dt-text-link: var(--dt-text-link-brand);
  --dt-text-link-hover: var(--dt-text-link-brand-hover);
  --dt-surface-selected: var(--dt-surface-selected-brand);
  --dt-surface-selected-hover: var(--dt-surface-selected-brand-hover);
  --dt-text-on-selected: var(--dt-text-on-selected-brand);
  --dt-border-selected: var(--dt-border-selected-brand);
  --dt-focus-ring-color: var(--dt-focus-ring-color-brand);
}

4. Override semantic roles only where the brand disagrees

If your brand's action colour needs to be something else again, say darker for contrast, or a different hue from your links, override the semantic role directly.

:root {
  --dt-surface-action: var(--dt-color-primary-700);
  --dt-text-on-action: var(--dt-color-white);
}

Always move the pair together. Changing a surface without checking its foreground is how a theme ships a 2.8:1 button.

5. Set the typeface

Three lines, plus the roles you want to diverge.

:root {
  --dt-font-family-sans: "Acme Grotesk", ui-sans-serif, system-ui, sans-serif;
  --dt-font-family-serif: "Acme Text", ui-serif, Georgia, serif;
  --dt-font-family-mono: "Acme Mono", ui-monospace, Menlo, monospace;
}

The secondary family, --dt-font-family-secondary, is the small UI voice: the label and eyebrow roles read it, so buttons, form labels, badges, tabs and eyebrows follow it. It points at the sans family until you set it.

:root {
  --dt-font-family-secondary: "Acme Mono", ui-monospace, monospace;
}

To give headings a different family from body copy, override the role families:

:root {
  --dt-text-heading-xl-family: var(--dt-font-family-serif);
  --dt-text-heading-lg-family: var(--dt-font-family-serif);
}

6. Set the shape language

Five lines control the system's entire corner treatment.

:root {
  --dt-radius-control: var(--dt-radius-raw-full);  /* pill buttons */
  --dt-radius-container: var(--dt-radius-raw-16);  /* soft cards */
  --dt-radius-overlay: var(--dt-radius-raw-24);
  --dt-radius-media: var(--dt-radius-raw-12);
  --dt-radius-pill: var(--dt-radius-raw-full);
}

7. Verify

  • Light and dark, side by side.
  • Every semantic pair at AA. guidelines/accessibility.md lists the pairs to check.
  • The focus ring, against your darkest and lightest surfaces.
  • A disabled control, since disabled states are where themes most often lose contrast.

Where a token may be declared

A custom property resolves where it is declared, not where it is read. That rule decides where each tier may live:

  • Primitives and semantic roles are declared on :root, and the semantic roles again under .dark. A .dark class on any element, the page or one band inside it, re-points every role below it.
  • Component aliases (--dt-button-*, --dt-card-* and the rest) are declared on :root, and their colour aliases are repeated under .dark. Without the repeat, a ghost button inside a dark band keeps the text colour it resolved to on the light page and measures about 1.1:1 against its own background. Sizing, shape and elevation aliases are deliberately not repeated, so a context's retuning of them still reaches inside a dark band.
  • A theme that adds a component alias of its own follows the same rule: declare it on :root, and if its value reads a colour role, declare it again under .dark.

A band that should not follow the page

Some sections are fixed: a tan editorial band that stays tan, a cinema-dark stage that stays dark, whatever the visitor's colour mode. Build the band's background from a brand role or a primitive, not a page surface, and re-point --dt-text-primary, -secondary and -tertiary on the band's own selector. Every component inside that reads the semantic text roles follows with no per-component styling. Section does exactly this for its brand, secondary and -muted tones; hand-write it only for a colour the brand roles do not cover.

.stage {
  background: var(--dt-color-neutral-950);
  --dt-text-primary: var(--dt-color-neutral-050);
  --dt-text-secondary: var(--dt-color-neutral-300);
  --dt-text-tertiary: var(--dt-color-neutral-400);
  color: var(--dt-text-primary);
}

Add the dark class as well when the band holds controls, so buttons and inputs take their dark roles too.

What a theme must not do

Don't add new token names. A consumer switching themes would get undefined variables. If a name is missing, it belongs in the system, not in one theme.

Don't reach into component internals. No .dt-button { … } rules. If a component cannot be themed through its tokens, the component needs a token, not a CSS override.

Don't skip the dark variant. If you override a semantic colour role at :root, check whether .dark needs the matching override. Otherwise your brand disappears in dark mode.

Don't theme away accessibility. Feedback colours may shift hue, but error states must stay chromatically distinct from success states. A monochrome error is a failure.

Worked examples

Two demo themes ship with the system, chosen to sit at opposite poles.

theme-editorial.css: warm terracotta primary, warmed neutrals, serif display and headings, pill controls, generous radii, an off-white page ground. Changes hue, typeface, and shape at once.

theme-mono.css: no brand hue at all. Action colour is the darkest neutral, corners are square, elevation is expressed as border weight rather than shadow, and labels are set in mono. Feedback colours stay chromatic.

Between them they exercise every part of the contract. If a component looks right under both, it is genuinely theme-independent.

Density

Density is a separate axis from brand, and they compose. density-compact.css retunes control heights and inset spacing only. Type, colour, and shape are untouched, so a compact view is recognisably the same product.

Scope it to a subtree when only part of a screen is dense:

<div class="dt-density-compact">
  <!-- data table -->
</div>

Source: system/guidelines/theming.md