Skip to content

Theming

Airframe is customized with CSS variables. Override --af-* tokens in your stylesheet — no build step, no config file.

Four color schemes ship built-in: light, dark, high-contrast-light, and high-contrast-dark. Dark follows prefers-color-scheme; high contrast follows prefers-contrast.

Force light or dark with data-theme="light" / data-theme="dark" (or .dark) — the same attribute Starlight, next-themes, and most app shells already use.

Named brands are a separate axis: data-brand holds a palette. It can sit on <html> or any subtree, and it composes with light / dark / high contrast.

Full token lists live in Tokens and Tokens full list. This page is the workflow: brand once, then tune light / dark / high contrast.

Override primitives in :root for a global brand change. Everything that depends on those tokens updates:

:root {
  --af-base-primary: #0876dd;
  --af-base-secondary: #6b7280;
  --af-base-success: #10b981;
  --af-base-danger: #ef4444;
  --af-base-warning: #f59e0b;
  --af-base-info: #3b82f6;
  --af-font-body: 'Inter', system-ui, sans-serif;
  --af-radius: 0.5rem;
}

When to use: One palette, same hex in every scheme — the usual first step.

Limitation: An unsuffixed token is the resolved color. Setting --af-base-primary: #c23018 pins that hex across light and dark. For different colors per scheme, or more than one brand, use suffixes and data-brand (below).

Two independent axes. Components never see either attribute — they read unsuffixed tokens like --af-color-primary.

AttributeMeaningValues
data-themeMode (light / dark)light, dark only. Omit to follow the OS. Same contract as Starlight / next-themes.
data-brandNamed paletteAny name you choose (delta, vortex, …). Inherits to the subtree.

High contrast is not a data-theme value. It stays on prefers-contrast.

<html data-theme="dark" data-brand="delta">
  <body>
    <main>Delta, dark mode</main>
    <aside data-brand="vortex">Vortex, still dark</aside>
  </body>
</html>

data-theme accepts only light and dark. Do not put brand names or hc-light / hc-dark on it — that collides with the libraries above and with Airframe’s scheme switch.

Each brand is a CSS block that sets stored suffixes (--light, --dark, --hc-light, --hc-dark). Airframe points the unsuffixed token at the active scheme.

[data-brand='delta'] {
  --af-base-primary--light: #c23018;
  --af-base-primary--dark: #e85d2a;
  --af-base-primary--hc-light: #9a2a10;
  --af-base-primary--hc-dark: #f08a5c;
  --af-color-background--light: #fefaf8;
  --af-color-background--dark: #1a0b08;
  --af-color-surface-secondary--light: #f9ece7;
  --af-color-surface-secondary--dark: #2a1410;
  --af-font-heading: 'Delta Sans', system-ui, sans-serif;
}

[data-brand='vortex'] {
  --af-base-primary--light: #00d2cc;
  --af-base-primary--dark: #00e0d4;
  --af-color-on-primary--light: #042628;
  --af-color-on-primary--dark: #042628;
  --af-color-background--light: #061516;
  --af-color-background--dark: #030a0b;
  --af-color-text--light: #e8fafa;
  --af-color-text--dark: #e8fafa;
  --af-color-surface-tertiary--light: #010101;
  --af-color-surface-tertiary--dark: #010101;
}

Omit any suffix you do not need. --dark falls back to --light for intent primitives. Surfaces have built-in dark defaults if you skip --af-color-background--dark.

Put on [data-brand]Leave alone
--af-base-primary--light / --dark / --hc-* (intents)Unsuffixed --af-base-primary, --af-color-*
--af-color-background--light / --dark (surfaces, text)--af-color-primary-hover and other color-mix
Fonts, radius, control height if they differ per brandBreakpoints, z-index, motion unless you must
document.documentElement.setAttribute('data-brand', 'delta');
document.documentElement.setAttribute('data-theme', 'dark');

// Scoped: only this subtree changes palette
panel.setAttribute('data-brand', 'vortex');

Patterns use --af-color-primary, --af-color-background, --af-color-text. They do not read --light, data-theme, or data-brand. When the attributes change, those resolved variables update and the UI follows.

Each color has stored scheme values and one resolved token that components actually use:

  • Stored: --af-base-primary--light, --af-base-primary--dark, --af-base-primary--hc-light, --af-base-primary--hc-dark
  • Resolved: --af-base-primary — Airframe points this at the active scheme

Light mode reads --light. Dark mode reassigns the resolved token to --dark. High contrast reassigns it to --hc-light or --hc-dark. All four stored values can exist at once on [data-brand]; only the active scheme is applied.

If you set the resolved token yourself:

[data-brand='delta'] {
  --af-base-primary: #c23018; /* resolved — pins this hex in every scheme */
  --af-base-primary--dark: #e85d2a; /* stored, but never read */
}

--af-base-primary: #c23018 replaces Airframe’s var(--af-base-primary--light) pointer. Dark mode has nothing left to reassign, so --dark is ignored. --af-base-primary--light is the light input; leave the unsuffixed name to Airframe.

Same rule for semantic colors: set --af-color-background--light and --af-color-background--dark, not --af-color-background.

Unsuffixed --af-base-primary in :root is still valid when you want one hex everywhere.

Dark mode follows prefers-color-scheme automatically after you import the core CSS. To override the system:

<!-- Force dark -->
<html data-theme="dark">
  <body>...</body>
</html>

<!-- Force light -->

<html data-theme="light">
  <body>...</body>
</html>

<!-- Also supported -->

<html class="dark">
  <body>...</body>
</html>

data-theme is the brand’s light or dark scheme. Nested data-theme="light" on a panel still reads --af-color-text--light. If that brand stores a dark page in --light, the panel stays dark.

.af-force-light / .af-force-dark lock a subtree to the catalog light or dark canvas (surfaces, text, borders). Intents (--af-color-primary, --af-color-on-primary) still follow the live brand.

Use this for chrome that must stay light-on-white or dark-on-near-black regardless of scheme or a dark-first --light palette: a Figma-style panel, a terminal card, a marketing band.

<figure class="af-force-light">
  <button class="af-btn af-is-secondary af-is-outline">Always dark ink</button>
</figure>

<section class="af-force-dark">
  <input class="af-input" placeholder="Always light ink" />
</section>
LayerPrefixUse for
Primitives--af-base-*Simple global brand swaps
Semantic--af-color-*Surfaces, text, borders, per-scheme control

Override stored tokens with suffixes --light, --dark, --hc-light, or --hc-dark. All intent primitives (primary, secondary, tertiary, success, warning, danger, info) support the same split. If you omit --dark, dark mode keeps the --light hex.

:root {
  --af-base-success--light: #16a34a;
  --af-base-success--dark: #4ade80;
  --af-base-danger--hc-light: #b91c1c;
  --af-color-action--hc-light: #0066cc;
}

When to use: Different accents per scheme, stronger colors for high contrast, fine-grained surfaces and text.

Hover, active, soft, and hard variants are derived via color-mix():

:root {
  --af-hover-mix: 12%;
  --af-active-mix: 18%;
  --af-soft-mix: 12%;
  --af-hard-mix: 45%; /* AA text on soft intent surfaces */
}

Badges, chips, and KPI deltas use *-hard text on *-soft backgrounds. Keep --af-hard-mix high enough that success/warning/info hard text clears 4.5:1 on soft.

When you import the core CSS, dark mode follows prefers-color-scheme. No extra setup.

Override dark colors with the --dark suffix. This works whether dark mode comes from the media query, data-theme, or .dark:

:root {
  --af-color-background--dark: #0a0a0a;
  --af-color-surface-primary--dark: #0a0a0a;
  --af-color-text--dark: #ffffff;
  --af-color-border--dark: #333333;
  --af-base-primary--dark: #38bdf8;
}

Pair a custom light surface with --af-color-background--light. You can still override --af-base-primary for a global change; --light / --dark suffixes give scheme-specific control.

High-contrast schemes apply automatically when the OS enables high contrast (prefers-contrast: more or high). If dark mode and high contrast are both on, high-contrast dark tokens apply.

Override with --hc-light and --hc-dark on stored tokens (or primitives like --af-base-primary--hc-light):

:root {
  --af-color-background--hc-light: #ffffff;
  --af-color-surface-primary--hc-light: #ffffff;
  --af-color-text--hc-light: #000000;
  --af-color-text-muted--hc-light: #333333;
  --af-color-border--hc-light: #000000;
  --af-color-action--hc-light: #0066cc;
  --af-color-on-action--hc-light: #ffffff;

  --af-color-background--hc-dark: #000000;
  --af-color-surface-primary--hc-dark: #1a1a1a;
  --af-color-text--hc-dark: #ffffff;
  --af-color-text-muted--hc-dark: #cccccc;
  --af-color-border--hc-dark: #ffffff;
  --af-color-action--hc-dark: #60a5fa;
  --af-color-on-action--hc-dark: #000000;
}

Always verify contrast against WCAG 2.2 AA (4.5:1 normal text, 3:1 large text / UI). Use the contrast checker before shipping.

TokenHC LightHC Dark
background--af-color-background--hc-light--af-color-background--hc-dark
surface-primary--af-color-surface-primary--hc-light--af-color-surface-primary--hc-dark
text--af-color-text--hc-light--af-color-text--hc-dark
text-muted--af-color-text-muted--hc-light--af-color-text-muted--hc-dark
border--af-color-border--hc-light--af-color-border--hc-dark
action--af-color-action--hc-light--af-color-action--hc-dark
on-action--af-color-on-action--hc-light--af-color-on-action--hc-dark

Enable high contrast in the OS, or simulate prefers-contrast: more in DevTools. Check light and dark.

Same mechanism — override tokens in :root or [data-brand]. Examples:

:root {
  --af-font-primary: 'Inter', system-ui, sans-serif;
  --af-font-heading: 'Inter', system-ui, sans-serif;
  --af-control-min-height: 2.75rem;
  --af-btn-radius: var(--af-pill-radius);     /* optional: capsule CTAs */
  --af-input-radius: var(--af-radius-sm);     /* optional: tighter fields */
  --af-card-radius: var(--af-radius-lg);      /* boxed UI + overlays */
  --af-duration-fast: 120ms;
}

See Typography for type roles and Tokens for the full catalog (spacing, radius, shadows, motion, component tokens).

:root {
  --af-hover-mix: 12%;
  --af-hard-mix: 45%;
  --af-font-heading: ui-sans-serif, system-ui, sans-serif;
  --af-control-min-height: 2.5rem;
  --af-radius: 8px;
}

[data-brand='delta'] {
  --af-base-primary--light: #c23018;
  --af-base-primary--dark: #e85d2a;
  --af-color-background--light: #fefaf8;
  --af-color-background--dark: #1a0b08;
  --af-color-surface-secondary--light: #f9ece7;
  --af-color-surface-secondary--dark: #2a1410;
}

If you already have --color-* (or --bs-*, --mdc-*), map once into --af-*. Then stop duplicating semantics in two places.

For a product-owned namespace (gradients, elevation, display type) that should export to Figma as --demo-*, use BYO Tokens (theme.byoTokens.prefix) and optionally bridge into --af-*.

:root {
  --af-base-primary: var(--color-primary);
  /* After the spike, invert: --color-* aliases --af-* until old SCSS dies */
}

Pick one canonical set for semantic colours Airframe patterns consume. Two parallel palettes forever is how a dual look leaks into every template. BYO tokens beside --af-* are fine when they are inventory or bridge cleanly. See Use as much as you need.

Extra --af-* in theme.files export with af theme export (DTCG + plugin payload). Lint warns (extension-token); it does not fail. Stay in the --af-* namespace. Derived *-hover / *-active / *-soft / *-hard stay unmapped.

Unsuffixed hex overlays light only. Add --dark (and --hc-* if needed) for the other Figma modes. Prefer plain hex, px, or rem for extra tokens you invent. Catalog aliases already export as links between tokens.

:root {
  --af-chart-1: #4f46e5;
  --af-chart-1--dark: #a5b4fc;
}

Brand in CSS on this page, then export the same palette into Figma Variables. The project stays the source of truth.

Step-by-step: Figma plugin. Product overview: Airframe → Figma. Export layout and CLI flags: Theme package.