crefft Source 2026.10.4

Design Systems

crefft > Design > Design Systems

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.


Overview

A design system is the single source of truth for visual design decisions. It ensures consistency across interfaces, accelerates development, and makes design intent explicit through code.

Quick Reference

ConceptImplementationPurpose
Design tokensCSS custom propertiesSingle source of truth for values
PrimitivesBase componentsBuilding blocks for composition
Semantic tokensNamed by purposeAbstract meaning from raw values
ThemingToken overridesSupport light/dark/custom themes

Design Tokens

What Are Design Tokens?

Design tokens are named values representing design decisions. Instead of hardcoding #3b82f6, use var(--color-primary). This abstraction enables:

  • Consistent values across the codebase
  • Theme switching without code changes
  • Design changes without hunting for hex values
  • Clear communication between designers and developers

Token Architecture

Tokens MUST be organized in three layers:

┌─────────────────────────────────────────────────────────────┐
│  Component Tokens (highest specificity)                     │
│  --button-bg, --card-border, --input-focus-ring            │
├─────────────────────────────────────────────────────────────┤
│  Semantic Tokens (meaning-based)                            │
│  --color-primary, --text-secondary, --surface-elevated     │
├─────────────────────────────────────────────────────────────┤
│  Primitive Tokens (raw values)                              │
│  --blue-500, --gray-100, --space-4                         │
└─────────────────────────────────────────────────────────────┘

Why three layers?

  • Primitives define the palette of available values
  • Semantic tokens assign meaning to primitives
  • Component tokens allow component-specific customization

Components SHOULD reference semantic tokens. Semantic tokens MUST reference primitives. Primitives MUST contain raw values.


Typography

Font Stack

Each project MUST choose its own typefaces and record the choice, license, font version, and any modifications in its own design record. crefft does not prescribe a typeface.

  • Selected fonts MUST have a license permitting self-hosting and modification; SIL Open Font License 1.1 is a common case. Projects MUST retain required license and copyright notices, honor any Reserved Font Names, and record modifications, including subsetting and format conversion.
  • Fonts MUST provide real files or variable-font axes for every used weight. Synthesized bold MUST NOT replace missing weights.
  • Projects MUST verify glyph coverage against their content, including IPA and non-Latin text wherever used, and preserve that coverage when subsetting.
  • Code MUST use a monospace face with true bold and italic, including bold italic wherever those styles are combined.
  • Font files MUST be self-hosted. Pages MUST NOT make third-party font requests.
  • Projects MUST define complete font stacks with fallbacks.

Why: Licensing and modification records make font delivery auditable. Real weights and glyph coverage preserve legibility without synthetic styles or missing characters. True code styles keep emphasis distinct, and self-hosting avoids third-party tracking and availability dependencies.

DO: Record the selected faces and licenses, then ship verified font files locally.
DON'T: Copy a standard's example face, synthesize bold, or fetch fonts from a third party.

The names below are example aliases, not typeface recommendations. Replace the aliases and files with the project’s selected variable fonts:

@font-face {
  font-family: 'Project Text';
  src: url('/fonts/project-text-variable.woff2') format('woff2');
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
}

@font-face {
  font-family: 'Project Code';
  src: url('/fonts/project-code-variable.woff2') format('woff2');
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
}

@font-face {
  font-family: 'Project Code';
  src: url('/fonts/project-code-italic-variable.woff2') format('woff2');
  font-style: italic;
  font-weight: 400 700;
  font-display: swap;
}

:root {
  --font-sans: 'Project Text', system-ui, sans-serif;
  --font-mono: 'Project Code', ui-monospace, monospace;
  font-family: var(--font-sans);
  font-synthesis: none;
}

code, pre, kbd, samp {
  font-family: var(--font-mono);
}

Type Scale

The type scale MUST be based on a consistent ratio. Recommended: Major Third (1.25) or Perfect Fourth (1.333).

:root {
  --text-xs:   0.75rem;   /* 12px */
  --text-sm:   0.875rem;  /* 14px */
  --text-base: 1rem;      /* 16px - body text */
  --text-lg:   1.25rem;   /* 20px */
  --text-xl:   1.5rem;    /* 24px */
  --text-2xl:  2rem;      /* 32px */
  --text-3xl:  2.5rem;    /* 40px */
}

Usage guidelines:

SizeUse Case
--text-xsCaptions, badges, timestamps
--text-smSecondary text, table cells, form labels
--text-baseBody text, primary content
--text-lgSection headers, card titles
--text-xlPage titles
--text-2xlHero numbers, dashboard metrics
--text-3xlMarketing headlines (sparingly)

Font Weights

:root {
  --font-normal:   400;  /* Body text */
  --font-medium:   500;  /* Emphasized text, labels */
  --font-semibold: 600;  /* Headings, important values */
  --font-bold:     700;  /* Hero numbers, critical emphasis */
}

Line Height

:root {
  --leading-none:    1;      /* Single-line elements */
  --leading-tight:   1.25;   /* Headings */
  --leading-snug:    1.375;  /* Subheadings */
  --leading-normal:  1.5;    /* Body text (default) */
  --leading-relaxed: 1.625;  /* Long-form content */
}

Body text MUST use --leading-normal (1.5) minimum for readability.


Color System

Color Space

Colors SHOULD be defined in OKLCH for perceptual uniformity:

/* OKLCH: Lightness, Chroma, Hue */
--blue-500: oklch(58% 0.20 250);

Why OKLCH?

  • Perceptually uniform: equal steps look equal
  • Predictable lightness across hues
  • Better for generating color scales programmatically

When OKLCH is not supported, provide HSL or hex fallbacks.

Color Architecture

Primitive Colors

Raw color values forming the complete palette:

:root {
  /* Gray scale */
  --gray-50:  #fafafa;
  --gray-100: #f4f4f5;
  --gray-200: #e4e4e7;
  --gray-300: #d4d4d8;
  --gray-400: #a1a1aa;
  --gray-500: #71717a;
  --gray-600: #52525b;
  --gray-700: #3f3f46;
  --gray-800: #27272a;
  --gray-900: #18181b;
  --gray-950: #09090b;

  /* Primary (example: indigo) */
  --primary-50:  #eef2ff;
  --primary-100: #e0e7ff;
  --primary-200: #c7d2fe;
  --primary-300: #a5b4fc;
  --primary-400: #818cf8;
  --primary-500: #6366f1;
  --primary-600: #4f46e5;
  --primary-700: #4338ca;
  --primary-800: #3730a3;
  --primary-950: #1e1b4b;

  /* Success (green) */
  --success-50:  #ecfdf5;
  --success-300: #6ee7b7;
  --success-400: #34d399;
  --success-700: #047857;
  --success-800: #065f46;
  --success-950: #022c22;

  /* Warning (amber) */
  --warning-50:  #fffbeb;
  --warning-300: #fcd34d;
  --warning-400: #fbbf24;
  --warning-700: #b45309;
  --warning-800: #92400e;
  --warning-950: #451a03;

  /* Error (red) */
  --error-50:  #fef2f2;
  --error-300: #fca5a5;
  --error-400: #f87171;
  --error-700: #b91c1c;
  --error-800: #991b1b;
  --error-950: #450a0a;

  /* Info (blue) */
  --info-50:  #eff6ff;
  --info-300: #93c5fd;
  --info-400: #60a5fa;
  --info-700: #1d4ed8;
  --info-800: #1e40af;
  --info-950: #172554;
}

Primitives carry no accessibility guarantee. --primary-500 is legible on white as a fill but not as body text; --gray-400 is legible on black but not on white. A primitive becomes safe only once a semantic token pairs it with an approved background, as the contrast tables below do. Primitives are not part of a component’s API — components reference semantic tokens, as Token Architecture requires.

Semantic Colors

Named by purpose, not appearance. Every semantic foreground token MUST declare the role it serves — text, icon, control boundary, or fill — because WCAG sets a different threshold for each. A token MUST NOT be reused in another role until the new pairing has been measured.

:root {
  color-scheme: light;

  /* Surfaces: the approved backgrounds for every text token below */
  --surface-page:     var(--gray-50);
  --surface-card:     #ffffff;
  --surface-elevated: #ffffff;
  --surface-inset:    var(--gray-100);

  /* Text: 4.5:1 or better on every surface above */
  --text-primary:   var(--gray-900);
  --text-secondary: var(--gray-700);
  --text-muted:     var(--gray-600);
  --text-disabled:  var(--text-muted);
  --text-on-fill:   #ffffff;

  /* Icons and control boundaries: 3:1 or better (SC 1.4.11) */
  --icon-default:   var(--gray-700);
  --icon-subtle:    var(--gray-500);
  --border-control: var(--gray-500);

  /* Decorative rules: convey nothing, so no minimum applies */
  --border-default:  var(--gray-200);
  --border-muted:    var(--gray-100);
  --border-emphasis: var(--gray-300);

  /* Interactive: text or icon on a surface, or a fill under --text-on-fill */
  --interactive-default: var(--primary-600);
  --interactive-hover:   var(--primary-700);
  --interactive-active:  var(--primary-800);

  /* Status: one text/icon value, one tint surface, one solid fill per hue */
  --status-success:            var(--success-700);
  --status-success-surface:    var(--success-50);
  --status-success-on-surface: var(--success-800);
  --status-success-fill:       var(--success-800);

  --status-warning:            var(--warning-700);
  --status-warning-surface:    var(--warning-50);
  --status-warning-on-surface: var(--warning-800);
  --status-warning-fill:       var(--warning-800);

  --status-error:            var(--error-700);
  --status-error-surface:    var(--error-50);
  --status-error-on-surface: var(--error-800);
  --status-error-fill:       var(--error-800);

  --status-info:            var(--info-700);
  --status-info-surface:    var(--info-50);
  --status-info-on-surface: var(--info-800);
  --status-info-fill:       var(--info-800);

  /* Rings: solid indicator plus an optional translucent halo */
  --focus-ring-color:  var(--interactive-default);
  --focus-ring-width:  2px;
  --focus-ring-offset: 2px;
  --focus-ring-halo:   color-mix(in srgb, var(--interactive-default) 25%, transparent);
  --error-ring-halo:   color-mix(in srgb, var(--status-error) 25%, transparent);
}

Why the role split? crefft requires 4.5:1 for all text, while non-text icons, control boundaries, and focus indicators require 3:1 (SC 1.4.11). A single --status-error tuned for a 3:1 badge fill fails as error-message text; a single value tuned for text is needlessly dark as a fill. Naming the role makes the applicable threshold, and therefore the required test, unambiguous.

Why --text-disabled is separate. WCAG SC 1.4.3 exempts inactive UI components, but crefft’s all-text rule does not. Disabled labels MUST still reach 4.5:1, so the token aliases --text-muted rather than a low-contrast gray. Keeping a separate semantic role lets projects style disabled controls without weakening text contrast. Placeholders MUST use --text-muted or stronger; they are not exempt under WCAG either.

/* ✗ DON'T: a 3:1 icon token as text (4.39:1), or a primitive as
   placeholder (2.33:1) */
.hint        { color: var(--icon-subtle); background: var(--surface-inset); }
.placeholder { color: var(--gray-400); background: var(--surface-inset); }

/* ✓ DO: use the token whose role matches the pixels (7.03:1 for both) */
.hint        { color: var(--text-muted); background: var(--surface-inset); }
.placeholder { color: var(--text-muted); background: var(--surface-inset); }

Alpha and Translucent Tokens

var() takes a custom-property name and an optional fallback only. It has no slash syntax, so var(--status-error / 0.15) is a parse error and the whole declaration is dropped — silently removing a focus ring in a rule that has already set outline: none. Translucent values MUST be produced with color-mix(), Baseline widely available since May 2023 (Chrome 111, Edge 111, Firefox 113, Safari 16.2).

/* ✗ DON'T: var() has no alpha channel */
.form-input:focus { box-shadow: 0 0 0 3px var(--interactive-default / 0.15); }

/* ✓ DO: mix the token with transparent, and keep a solid indicator */
.form-input:focus-visible {
  outline: var(--focus-ring-width) solid var(--focus-ring-color);
  outline-offset: var(--focus-ring-offset);
  box-shadow: 0 0 0 3px var(--focus-ring-halo);
}

The halo is decoration. A translucent 25% mix cannot reach the 3:1 that SC 1.4.11 demands of a focus indicator, so the solid outline MUST remain.

Dark Mode

Dark mode MUST NOT simply invert colors. It requires intentional design, and it MUST redefine every semantic token the light theme defines — a partial override leaves light-theme surfaces underneath dark-theme text:

:root[data-theme="dark"] {
  color-scheme: dark;

  /* Surfaces: true blacks for OLED efficiency */
  --surface-page:     #000000;
  --surface-card:     #0a0a0a;
  --surface-elevated: #141414;
  --surface-inset:    #050505;

  /* Text: reduced brightness, still 4.5:1 on every surface above */
  --text-primary:   var(--gray-100);
  --text-secondary: var(--gray-300);
  --text-muted:     var(--gray-400);
  --text-disabled:  var(--text-muted);
  --text-on-fill:   var(--gray-950);

  --icon-default:   var(--gray-300);
  --icon-subtle:    var(--gray-500);
  --border-control: var(--gray-500);

  /* Borders: subtle in dark mode */
  --border-default:  var(--gray-800);
  --border-muted:    #1a1a1a;
  --border-emphasis: var(--gray-700);

  /* Interactive: lighter fills, so the on-fill foreground becomes dark */
  --interactive-default: var(--primary-400);
  --interactive-hover:   var(--primary-300);
  --interactive-active:  var(--primary-200);

  --status-success:            var(--success-400);
  --status-success-surface:    var(--success-950);
  --status-success-on-surface: var(--success-300);
  --status-success-fill:       var(--success-400);

  --status-warning:            var(--warning-400);
  --status-warning-surface:    var(--warning-950);
  --status-warning-on-surface: var(--warning-300);
  --status-warning-fill:       var(--warning-400);

  --status-error:            var(--error-400);
  --status-error-surface:    var(--error-950);
  --status-error-on-surface: var(--error-300);
  --status-error-fill:       var(--error-400);

  --status-info:            var(--info-400);
  --status-info-surface:    var(--info-950);
  --status-info-on-surface: var(--info-300);
  --status-info-fill:       var(--info-400);
}

Dark mode principles:

  1. Reduce overall brightness to prevent eye strain
  2. Use true black (#000) for OLED efficiency when appropriate
  3. Maintain or increase contrast ratios
  4. Flip the on-fill foreground: light fills need dark ink
  5. Test semantic colors for visibility
  6. Consider dimming images slightly

Why one selector for the whole palette? Splitting a theme across .dark and [data-theme="dark"] means one selector wins for surfaces and the other for text. Setting data-theme="dark" on a document whose dark palette is keyed to .dark leaves --surface-card at its light value while --text-primary flips: #f4f4f5 on #ffffff measures 1.09:1. Themes MUST therefore use exactly one selector contract, and --surface-* and --text-* MUST flip together.

Why :root[data-theme="dark"] rather than [data-theme="dark"]? A bare attribute selector has the same specificity as :root, so any later :root block that touches a themed token silently defeats the theme — the token reverts while its neighbors do not, which is the same broken pairing by another route. Theme selectors MUST out-specify the base :root block.

/* ✗ DON'T: two contracts, so surfaces and text disagree */
.dark { --surface-card: #0a0a0a; --text-primary: #f4f4f5; }
[data-theme="dark"] { --text-primary: #f4f4f5; }

/* ✓ DO: one contract, complete palette, out-specifying :root */
:root[data-theme="dark"] {
  --surface-card: #0a0a0a;
  --text-primary: #f4f4f5;
}

Contrast Requirements

All text MUST reach at least 4.5:1 against its actual background in every theme, regardless of size or weight. Non-text UI components, focus indicators, and essential graphics MUST reach at least 3:1 (SC 1.4.11).

Why: One text threshold simplifies testing, remains valid when users scale text, and removes size-classification disputes. The shared contrast rule distinguishes crefft’s requirement from WCAG 2.2 AA:

ElementWCAG 2.2 AAcrefft
Normal text, below the large-scale threshold4.5:14.5:1
Large text: 18pt regular, or 14pt bold3:14.5:1
Non-text UI components and essential graphics3:13:1
Focus indicators3:13:1

WCAG defines large-scale text in points, not CSS pixels. At 1pt = 1.333px, 18pt is approximately 24 CSS px and 14pt bold is approximately 18.67 CSS px. WCAG’s own note rounds these to “approximately 18.5px and 24px”; 18 CSS px normal-weight text is not large text even under WCAG. None of these size boundaries lowers crefft’s 4.5:1 text threshold.

Three further rules apply:

  • Ratios MUST NOT be rounded up. WCAG treats the thresholds as exact, so 4.499:1 fails 4.5:1.
  • Very thin or unusual faces render fainter than their declared color. Such faces SHOULD exceed the normative ratio rather than sit on it.
  • Placeholder text, hover text, focus-revealed text, and disabled labels MUST meet the same 4.5:1 text threshold.

Use tools like WebAIM Contrast Checker to verify.

Measured Token Pairings

Every pairing below was computed with the WCAG relative-luminance formula and truncated, never rounded up. The worst case for light-theme text is --surface-inset (#f4f4f5); for dark-theme text it is --surface-elevated (#141414).

Light-theme pairingRatioRequired
--text-primary on the worst surface16.11:14.5:1
--text-secondary on the worst surface9.50:14.5:1
--text-muted on the worst surface7.03:14.5:1
--text-disabled on the worst surface7.03:14.5:1
--icon-subtle / --border-control on worst surface4.39:13:1
--interactive-default as text on worst surface5.72:14.5:1
--text-on-fill on --interactive-default6.28:14.5:1
--text-on-fill on --interactive-hover7.90:14.5:1
--text-on-fill on --interactive-active9.93:14.5:1
--status-success on the worst surface4.98:14.5:1
--status-warning on the worst surface4.56:14.5:1
--status-error on the worst surface5.88:14.5:1
--status-info on the worst surface6.09:14.5:1
--status-*-on-surface on --status-*-surface6.83:1 +4.5:1
--text-on-fill on --status-*-fill7.09:1 +4.5:1
Dark-theme pairingRatioRequired
--text-primary on the worst surface16.76:14.5:1
--text-secondary on the worst surface12.46:14.5:1
--text-muted on the worst surface7.18:14.5:1
--text-disabled on the worst surface7.18:14.5:1
--icon-subtle / --border-control on worst surface3.81:13:1
--interactive-default as text on worst surface6.17:14.5:1
--text-on-fill on --interactive-default6.66:14.5:1
--text-on-fill on --interactive-hover9.97:14.5:1
--text-on-fill on --interactive-active13.33:14.5:1
--status-success on the worst surface9.58:14.5:1
--status-warning on the worst surface11.03:14.5:1
--status-error on the worst surface6.66:14.5:1
--status-info on the worst surface7.24:14.5:1
--status-*-on-surface on --status-*-surface8.14:1 +4.5:1
--text-on-fill on --status-*-fill7.19:1 +4.5:1

Only the decorative --border-default, --border-muted, and --border-emphasis tokens have no contrast minimum. They MUST be confined to non-meaningful rules, never text or essential control boundaries.

--status-warning has the least headroom in the light theme. Changing any light surface MUST trigger a re-run of the contrast suite below.

Testing Contrast

Contrast MUST be asserted in CI, once per theme, role, and state. Palettes drift; a test is the only thing that notices. The check needs no dependencies — Node.js 20 or later ships a stable node:test runner:

// tokens.contrast.test.mjs — run with: node --test tokens.contrast.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';

const channel = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);

const luminance = (hex) => {
  const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
  return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
};

const contrast = (a, b) => {
  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
};

// Worst-case background per theme, then one row per foreground token.
const SUITE = [
  { theme: 'light', bg: '#f4f4f5', rows: [
    ['--text-primary', '#18181b', 4.5],
    ['--text-secondary', '#3f3f46', 4.5],
    ['--text-muted', '#52525b', 4.5],
    ['--text-disabled', '#52525b', 4.5],
    ['--icon-subtle', '#71717a', 3],
    ['--border-control', '#71717a', 3],
    ['--interactive-default', '#4f46e5', 4.5],
    ['--status-success', '#047857', 4.5],
    ['--status-warning', '#b45309', 4.5],
    ['--status-error', '#b91c1c', 4.5],
    ['--status-info', '#1d4ed8', 4.5],
  ] },
  { theme: 'dark', bg: '#141414', rows: [
    ['--text-primary', '#f4f4f5', 4.5],
    ['--text-secondary', '#d4d4d8', 4.5],
    ['--text-muted', '#a1a1aa', 4.5],
    ['--text-disabled', '#a1a1aa', 4.5],
    ['--icon-subtle', '#71717a', 3],
    ['--border-control', '#71717a', 3],
    ['--interactive-default', '#818cf8', 4.5],
    ['--status-success', '#34d399', 4.5],
    ['--status-warning', '#fbbf24', 4.5],
    ['--status-error', '#f87171', 4.5],
    ['--status-info', '#60a5fa', 4.5],
  ] },
];

// Fills and tints are foreground/background pairs in their own right.
const PAIRS = [
  ['light interactive rest', '#ffffff', '#4f46e5', 4.5],
  ['light interactive hover', '#ffffff', '#4338ca', 4.5],
  ['light interactive active', '#ffffff', '#3730a3', 4.5],
  ['light error tint', '#991b1b', '#fef2f2', 4.5],
  ['dark interactive rest', '#09090b', '#818cf8', 4.5],
  ['dark interactive hover', '#09090b', '#a5b4fc', 4.5],
  ['dark interactive active', '#09090b', '#c7d2fe', 4.5],
  ['dark error tint', '#fca5a5', '#450a0a', 4.5],
];

for (const { theme, bg, rows } of SUITE) {
  for (const [token, fg, min] of rows) {
    test(`${theme}: ${token} on ${bg}`, () => {
      const ratio = contrast(fg, bg);
      assert.ok(ratio >= min, `${fg} on ${bg} is ${ratio.toFixed(4)}:1, needs ${min}:1`);
    });
  }
}

for (const [name, fg, bg, min] of PAIRS) {
  test(name, () => {
    const ratio = contrast(fg, bg);
    assert.ok(ratio >= min, `${fg} on ${bg} is ${ratio.toFixed(4)}:1, needs ${min}:1`);
  });
}

Extend SUITE whenever a theme is added and PAIRS whenever a new foreground/background combination ships. A token with no row is untested.


Spacing System

Base Unit

Spacing MUST be based on a consistent unit. Recommended: 4px base.

:root {
  --space-0:    0;
  --space-px:   1px;
  --space-0-5:  0.125rem;  /* 2px */
  --space-1:    0.25rem;   /* 4px */
  --space-1-5:  0.375rem;  /* 6px */
  --space-2:    0.5rem;    /* 8px */
  --space-3:    0.75rem;   /* 12px */
  --space-4:    1rem;      /* 16px */
  --space-5:    1.25rem;   /* 20px */
  --space-6:    1.5rem;    /* 24px */
  --space-8:    2rem;      /* 32px */
  --space-10:   2.5rem;    /* 40px */
  --space-12:   3rem;      /* 48px */
  --space-16:   4rem;      /* 64px */
  --space-20:   5rem;      /* 80px */
  --space-24:   6rem;      /* 96px */
}

Half-Step Names

A custom-property name is a <dashed-ident>, and an identifier MUST NOT contain an unescaped full stop. Browsers do not warn: they discard the declaration, var(--space-0.5) resolves to nothing, and the affected property falls back to its inherited or initial value. Half steps MUST therefore be spelled with a hyphen.

/* ✗ DON'T: invalid identifier, silently dropped */
:root { --space-0.5: 0.125rem; }

/* ✓ DO: valid dashed identifier */
:root { --space-0-5: 0.125rem; }

Escaping the stop is also valid, but the escape belongs to CSS syntax only: the declaration and every var() must be written --space-0\.5, while the CSSOM name is the unescaped --space-0.5, so getPropertyValue('--space-0\\.5') returns an empty string and getPropertyValue('--space-0.5') returns the value. That asymmetry breaks token pipelines and theme-inspection code, so escaping SHOULD NOT be used.

Usage Guidelines

ContextRecommended Spacing
Inline element gap--space-1 to --space-2
Related items--space-2 to --space-3
Component internal padding--space-3 to --space-4
Card padding--space-4 to --space-6
Section gap--space-6 to --space-8
Page sections--space-12 to --space-16

Never use arbitrary values. If the scale doesn’t fit, reconsider the design rather than introducing one-off spacing.


Border Radius

Radius Scale

:root {
  --radius-none: 0;
  --radius-sm:   4px;   /* Small elements: badges, tags */
  --radius-md:   6px;   /* Inputs, small buttons */
  --radius-lg:   8px;   /* Buttons, small cards */
  --radius-xl:   12px;  /* Cards, panels */
  --radius-2xl:  16px;  /* Large cards, modals */
  --radius-3xl:  24px;  /* Hero elements */
  --radius-full: 9999px; /* Pills, avatars */
}

Usage Guidelines

ElementRadius
Badges, tags--radius-sm
Inputs, select--radius-md
Buttons--radius-lg
Cards--radius-xl
Modals--radius-2xl
Avatars, status dots--radius-full

Consistency in radius creates visual harmony. Mixing many different radii creates visual discord.


Shadows

Shadow Scale

:root {
  --shadow-sm:
    0 1px 2px 0 rgb(0 0 0 / 0.05);

  --shadow-md:
    0 4px 6px -1px rgb(0 0 0 / 0.1),
    0 2px 4px -2px rgb(0 0 0 / 0.1);

  --shadow-lg:
    0 10px 15px -3px rgb(0 0 0 / 0.1),
    0 4px 6px -4px rgb(0 0 0 / 0.1);

  --shadow-xl:
    0 20px 25px -5px rgb(0 0 0 / 0.1),
    0 8px 10px -6px rgb(0 0 0 / 0.1);
}

Elevation System

LevelShadowUsage
0noneFlat elements, inline content
1--shadow-smCards at rest
2--shadow-mdCards on hover, dropdowns
3--shadow-lgModals, popovers
4--shadow-xlCommand palette, important overlays

In dark mode, shadows are less visible. Consider using subtle borders or background color differences to indicate elevation instead.


Z-Index

Z-Index Scale

Establish a z-index scale to prevent z-index wars:

:root {
  --z-base:           0;
  --z-dropdown:       10;
  --z-sticky:         20;
  --z-fixed:          30;
  --z-modal-backdrop: 40;
  --z-modal:          50;
  --z-popover:        60;
  --z-tooltip:        70;
  --z-toast:          80;
  --z-max:            9999;
}

Components MUST use these tokens. Arbitrary z-index values MUST NOT be used.


Theming

Theme Implementation

Themes MUST be implemented via CSS custom property overrides, and every theme MUST use the same selector contract. This guide uses data-theme on the root element throughout, because that is what the switcher below writes, and it qualifies each theme with :root so a theme rule always out-specifies the base block whatever the source order.

/* Base theme (light) - see "Semantic Colors" for the complete token set */
:root {
  color-scheme: light;
  --surface-page: var(--gray-50);
  --text-primary: var(--gray-900);
}

/* Dark theme - defined once, in full, under "Dark Mode" */

/* High contrast theme */
:root[data-theme="high-contrast"] {
  color-scheme: light;
  --surface-page: #ffffff;
  --text-primary: #000000;
  --border-default: #000000;
}

A theme MUST override the whole semantic layer, not a subset. Overriding --text-primary without --surface-card produces #f4f4f5 on #ffffff, a measured 1.09:1. A theme rule that redefines any --text-* token MUST also redefine every --surface-* token, and vice versa.

Theme Switching

The switcher stores the user’s preference — including system — and writes the resolved theme to data-theme, so the CSS only ever sees a concrete theme name:

const THEMES = ['light', 'dark', 'high-contrast'];
const systemDark = window.matchMedia('(prefers-color-scheme: dark)');

function resolveTheme(preference) {
  if (THEMES.includes(preference)) return preference;
  return systemDark.matches ? 'dark' : 'light';
}

function setTheme(preference) {
  document.documentElement.setAttribute('data-theme', resolveTheme(preference));
  localStorage.setItem('theme', preference);
}

setTheme(localStorage.getItem('theme') ?? 'system');

// Follow the system while the user has not chosen a specific theme
systemDark.addEventListener('change', () => {
  if ((localStorage.getItem('theme') ?? 'system') === 'system') setTheme('system');
});

Why resolve rather than pass through? data-theme="system" matches no rule, so the document silently falls back to the light palette. Resolving in JavaScript keeps one selector contract in CSS and lets the media query keep working after load.

Theme Requirements

All themes MUST:

  1. Meet 4.5:1 for all text and 3:1 for non-text UI and focus, verified by the contrast suite
  2. Redefine every semantic token, not a subset
  3. Use a single selector contract, qualified so it out-specifies base :root
  4. Be tested with all UI components
  5. Support prefers-color-scheme for users who have expressed no preference
  6. Persist user preference in localStorage
  7. Apply without page reload (CSS custom properties enable this)

Switching a theme MUST be verified by reading the computed foreground and background of a real component, not by inspecting the stylesheet.


Anti-Patterns

Hardcoded Values

/* ✗ DON'T: Hardcoded values */
.card {
  padding: 16px;
  border-radius: 8px;
  background: #ffffff;
}

/* ✓ DO: Design tokens */
.card {
  padding: var(--space-4);
  border-radius: var(--radius-xl);
  background: var(--surface-card);
}

Magic Numbers

/* ✗ DON'T: Arbitrary values */
.modal {
  z-index: 99999;
  padding: 23px;
}

/* ✓ DO: System values */
.modal {
  z-index: var(--z-modal);
  padding: var(--space-6);
}

One-Off Colors

/* ✗ DON'T: Inline colors */
.warning-text {
  color: #f5a623;
}

/* ✓ DO: Semantic tokens */
.warning-text {
  color: var(--status-warning);
}

Implementation Checklist

  • All values use design tokens (no hardcoded values)
  • Token architecture has three layers (primitive, semantic, component)
  • Every semantic foreground token names its role (text, icon, border, fill)
  • Project-selected fonts and licenses recorded, coverage and real weights verified
  • Type scale based on consistent ratio
  • Spacing based on consistent unit, half steps use valid identifiers
  • Dark mode intentionally designed (not inverted)
  • Every theme overrides the complete semantic layer under one selector
  • All text meets 4.5:1 and non-text UI and focus meet 3:1, asserted by an automated test
  • Translucent values use color-mix(), never a slash inside var()
  • Z-index uses defined scale
  • Theme switching works without page reload
  • System theme preference respected

See Also