Skip to content

Tokens and Theming

FieldValue
TypeSkill Resource
Source~/.copilot/skills/design/references/tokens-and-theming.md
DescriptionNot specified

Source Content

Tokens and Theming

Contents:

Three token layers: primitive, semantic, component

A token is a CSS custom property. Tokens are the only place a raw value (hex, rgb, hsl, px) is allowed to live. Everything else reads a token with var(--token). There are three layers, and mixing their jobs is the most common token mistake.

Primitive tokens

The raw palette — named by what they are, not where they are used. No opinion about light, dark, foreground, or background.

/* tokens.css — PRIMITIVE LAYER
Raw palette. Named by value, not by role. Components never touch these. */
:root {
--blue-600: 220 90% 56%; /* channels only — see "Store color channels" below */
--gray-900: 0 0% 10%;
--gray-50: 0 0% 98%;
--white: 0 0% 100%;
}

Semantic tokens

Named by role — what the value means in the UI. These are the only tokens a component is allowed to consume.

/* tokens.css — SEMANTIC LAYER
Named by role. These are the public token API. Components read ONLY these. */
:root {
--color-bg: var(--white); /* page surface */
--color-fg: var(--gray-900); /* primary text on --color-bg */
--color-primary: var(--blue-600); /* main action color */
--color-border: var(--gray-50); /* hairline dividers */
}

Rule: components consume semantic tokens only. Reaching past the semantic layer to a primitive (color: var(--blue-600)) breaks theming — the primitive does not flip. If a component needs a color the semantic layer does not expose, add a semantic token; do not borrow a primitive.

Component tokens

A third, narrower layer maps semantic tokens onto specific component parts — sizes, variants, states — so a component’s whole visual API lives in one named place instead of being re-derived at every call site.

// Variant tokens: each named variant maps every state to semantic values.
const variantTokens = {
primary: {
background: 'var(--color-primary)',
backgroundHover: 'var(--color-primary-600)',
text: 'var(--color-primary-foreground)',
border: 'transparent',
},
outline: {
background: 'transparent',
backgroundHover: 'var(--color-primary-50)',
text: 'var(--color-primary)',
border: 'var(--color-primary)',
},
ghost: {
background: 'transparent',
backgroundHover: 'var(--color-muted)',
text: 'var(--color-fg-muted)',
border: 'transparent',
},
};
// Size tokens: each size maps height/padding/font/icon to the scale.
const sizeTokens = {
sm: { height: '32px', paddingX: 'var(--space-3)', fontSize: 'var(--ui-type-body-sm-size)', iconSize: '16px' },
md: { height: '40px', paddingX: 'var(--space-4)', fontSize: 'var(--ui-type-body-size)', iconSize: '20px' },
lg: { height: '48px', paddingX: 'var(--space-6)', fontSize: 'var(--ui-type-title-sm-size)', iconSize: '24px' },
};

Component tokens still bottom out in semantic tokens — never a raw hex or literal spacing value. Naming convention: {category}-{property}-{variant}-{state} (e.g. color-primary-500-hover, spacing-md, radius-lg).

Theming: flip the values, never the classes

A theme is a block that re-points the same semantic token names at different primitives. The default (light) theme lives on :root. Each alternate theme overrides those same names under a [data-theme="…"] selector on the same element or an ancestor.

/* themes.css — THEME LAYER
Same semantic names, different values. Components never change between themes. */
/* Light / default */
:root {
--color-bg: var(--white);
--color-fg: var(--gray-900);
--color-primary: var(--blue-600);
}
/* Dark — identical token NAMES, re-pointed values */
[data-theme="dark"] {
--color-bg: var(--gray-900);
--color-fg: var(--gray-50);
--color-primary: var(--blue-600); /* primary may stay; bg/fg flip */
}

The component stays exactly the same in both themes — it only ever wrote var(--color-bg):

/* card/card.css — one rule, every theme. Nothing here knows light from dark. */
.card {
background: var(--color-bg);
color: var(--color-fg);
border: 1px solid var(--color-border);
}

Two rules that this pattern exists to enforce:

  • Never hardcode a color. A literal in a component cannot flip with the theme, so it is wrong in at least one theme.
  • Never conditionally swap classes per theme. No class={isDark ? "card--dark" : "card--light"}, no dark:bg-… duplicate of every utility. Let the tokens flip; the markup is theme-blind.

The dmwd-io theme surface

Real theme tokens shipped in src/index.css, hsl(var(--token)):

CSS varHSL (light)HSL ([data-theme="night"])Tailwind classUse
--background0 0% 100%220 14% 9%bg-backgroundPage background
--foreground48 3% 26%210 20% 94%text-foregroundPrimary text
--card0 0% 100%220 12% 12%bg-cardCard surface
--primary168 79% 25%168 65% 50%bg-primary / text-primaryActions, CTAs, selected state
--primary-foreground0 0% 100%166 23% 11%text-primary-foregroundText on primary
--secondary45 3% 97%-bg-secondarySubtle alternate surfaces
--muted45 3% 93%220 9% 18%bg-mutedEmpty states, skeleton, quiet zones
--muted-foreground45 4% 36%215 12% 70%text-muted-foregroundSupporting text, icons
--accent45 3% 95%-bg-accentHover state, highlight surface
--destructive5 74% 48%8 80% 63%bg-destructive / text-destructiveErrors, delete actions
--border45 10% 55%220 8% 30%border-borderDefault neutral border
--ring168 79% 25%-ring-ringFocus ring

Never use text-muted-foreground for primary interactive labels — only for supporting/secondary text.

Brand tokens

CSS varTailwind classLight HSLDark HSLUse
--brand-tealtext-brand-teal / bg-brand-teal168 79% 25%168 65% 50%Primary brand color
--brand-navytext-brand-navy / bg-brand-navy216 44% 20%214 46% 78%Deep accent complement
--brand-ambertext-brand-amber / bg-brand-amber38 92% 58%41 92% 64%Highlight / warning
--brand-misttext-brand-mist / bg-brand-mist44 35% 96%220 9% 18%Soft background tint

Status tokens

CSS varUse
--info / --info-foregroundInformational messages (light: 209 66% 37%)
--success / --success-foregroundConfirmation, completed state (aliases --primary)
--warning / --warning-foregroundCaution (aliases --brand-amber)
--destructive / --destructive-foregroundError, danger

Surface tokens and accent overrides

--surface-accent (aliases --primary), --surface-strong (deepened primary, light: 168 72% 18%), --surface-strong-foreground. When data-surface-style="palette" is on an ancestor, background/card/muted/border shift to be tinted by --accent-surface-hue.

[data-accent="name"] on any ancestor overrides --primary, --ring, --info, --success, --brand-teal, --brand-navy. 30+ named accents ship built-in across greens, blues, purples, reds/pinks, warm, and neutrals — each with day + night variants that switch automatically with [data-theme="night"].

Multiple themes are the default assumption

Design as if there will be many themes, not two. Light and dark are just the first two entries. A high-contrast theme, a brand sub-theme, or a per-tenant theme is a new selector block of the same semantic names — no component changes, no new classes.

/* themes.css — add a theme = add a block. That is the entire cost. */
[data-theme="high-contrast"] {
--color-bg: var(--white);
--color-fg: 0 0% 0%; /* pure black text */
--color-primary: 220 100% 40%;
--color-border: 0 0% 0%;
}

Because every theme defines the same names, a missing token in one theme is a real bug, not a fallback. Run ../scripts/check_theme_completeness.py to verify every token exists in every theme.

Store color channels so alpha is free

Store the color channels (HSL components, or the parts a modern color()/rgb() takes) rather than a finished color string. Then any consumer can apply alpha with one syntax — no second token per opacity level.

/* PRIMITIVE: bare channels, no hsl() wrapper */
:root { --blue-600: 220 90% 56%; }
/* SEMANTIC + CONSUMER: wrap at use, vary alpha freely */
.button { background: hsl(var(--color-primary)); }
.button:hover { background: hsl(var(--color-primary) / 0.9); } /* 90% — no new token */
.button__ghost { background: hsl(var(--color-primary) / 0.1); } /* 10% tint — no new token */

This is also what lets a utility framework apply opacity modifiers (e.g. bg-primary/10) without you defining a token for every step.

Map tokens to Tailwind once

Tailwind utilities and hand-written CSS must read from one source of truth. Map the semantic tokens into the Tailwind theme so bg-primary and var(--color-primary) resolve to the same value; config shape lives in ./css/tailwind-config.md. The takeaway here: a token is defined once in CSS and referenced by the config, never copied into it.

Tailwind class map (all overridden values)

All four rounded-* radius classes are overridden to read var(--radius-*) — see radius.md. Font families: font-sans → system stack, font-body / font-serif also resolve to the system stack (ADR-003, no web fonts — see typography.md). Box shadow: shadow-soft0 14px 24px -18px rgba(68, 68, 65, 0.22) for cards/panels needing depth without harsh shadows (avoid in @media print). Max width: max-w-measure68ch for optimal reading line length on prose blocks. Plugins active: @tailwindcss/forms (form element base resets), tailwindcss-animate (animate-in/animate-out/fade-*/zoom-*/slide-*).

Color discipline: literals live in one place

A raw color literal (#3b82f6, rgb(...), hsl(...), 220 90% 56%) is allowed only in the token-definition files — the primitive and semantic/theme blocks above. Everywhere else, every color is var(--token).

LocationRaw literal allowed?What to write
tokens.css primitive blockYes--blue-600: 220 90% 56%;
themes.css theme blocksYesre-point semantic names
Component CSSNohsl(var(--color-primary))
Utility classes in markupNobg-primary, mapped to the token

Stylelint flags stray color literals found outside the token-definition layer (see ./css/linting.md), so this rule is enforced, not just hoped for.

Always check contrast

Run the contrast checker on your token file whenever you add or change a color. It auto-pairs each --x-foreground against its matching --x and checks the pair against WCAG: 4.5:1 for normal text, 3:1 for large text and UI components (AA), and reports AAA where it is met (7:1 / 4.5:1).

Terminal window
python3 ~/.copilot/skills/design/scripts/check_contrast.py path/to/tokens.css

For the pairing to work, name foregrounds as --<surface>-foreground next to their surface — e.g. --color-primary / --color-primary-foreground, --color-bg / --color-fg is paired by convention too. Run it against every theme block, not just the default: a pair that passes in light can fail in dark. Details on the checker live in ../scripts/check_contrast.py.

Design-system contrast status (default light theme)

CombinationApprox ratioStatus
text-foreground on bg-background~14:1AAA
text-foreground on bg-card~14:1AAA
text-muted-foreground on bg-card~5.5:1AA
text-primary on bg-background~4.8:1AA
text-primary-foreground on bg-primary~7:1AAA
text-destructive-foreground on bg-destructive>4.5:1AA

Generating a token set from a brand color

Generate a full three-layer system from one brand color with ../scripts/design_token_generator.py:

Terminal window
python3 ~/.copilot/skills/design/scripts/design_token_generator.py "#0066CC" modern json
python3 ~/.copilot/skills/design/scripts/design_token_generator.py "#8B4513" classic css
python3 ~/.copilot/skills/design/scripts/design_token_generator.py "#FF6B6B" playful summary

Arguments: brand_color (hex, default #0066CC), style (modern | classic | playful, default modern), format (json | css | scss | summary, default json).

Color scale generation (HSV)

The generator converts hex → RGB → HSV, then for each step (50, 100, 200 … 900) adjusts value (brightness) and saturation while holding hue constant:

Light shades (step < 500): value fixed at 95% brightness.
Dark shades (step >= 500): value = base_value * (1 - (step - 500) / 500).
Saturation: base_saturation * (0.3 + 0.7 * (step / 900)) — rises with step.
Complementary/secondary color: hue + 180°.
StepUse caseBrightnessSaturation
50Subtle backgrounds95% (fixed)30%
300Borders95% (fixed)54%
500Base colorOriginal70%
700Active statesOriginal × 0.686%
900HeadingsOriginal × 0.2100%

Typography scale (modular, 1.25× major third default)

Base: 16px
Smaller: 16px ÷ 1.25^n Larger: 16px × 1.25^n
xs 10px · sm 13px · base 16px · lg 20px · xl 25px · 2xl 31px · 3xl 39px · 4xl 49px · 5xl 61px
RatioNameCharacter
1.125Major SecondSubtle — app interfaces
1.200Minor ThirdModerate — general use
1.250Major ThirdBalanced — default
1.333Perfect FourthPronounced — marketing
1.618Golden RatioDramatic — headlines

Spacing grid (8pt)

Base unit 8px; multipliers 0, 0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 6, 7, 8… produce 0, 4, 8, 12, 16, 20, 24… px. Semantic aliases: xs=4px, sm=8px, md=16px, lg=24px, xl=32px, 2xl=48px, 3xl=64px. Why 8pt: divides evenly into common screen widths, gives predictable vertical rhythm, and touch targets naturally land on 48px (8×6).

Style differences

AspectModernClassicPlayful
Border Radius8px default4px default16px default
ShadowsLayered, subtleSingle layerSoft, pronounced

Note: the generator’s own font-family defaults (Inter, Helvetica, Poppins, etc.) are historical scaffolding from the tool’s original design — the dmwd-io stack overrides these with the system font stack only (ADR-003; see typography.md). Treat the generator’s typography/font output as a structural starting point, not a license to load a web font.

Component architecture: naming, hierarchy, variants

Atomic hierarchy

Tokens (foundation) → atoms (Button, Input, Icon, Badge) → molecules (FormField, Card, ListItem) → organisms (Header, DataTable, Modal) → templates (DashboardLayout) → pages (HomePage).

CategoryDescriptionExamples
PrimitivesBase HTML wrapperBox, Text, Flex, Grid
InputsUser interactionButton, Input, Select, Checkbox
DisplayContent presentationCard, Badge, Avatar, Icon
FeedbackUser feedbackAlert, Toast, Progress, Skeleton
NavigationRoute managementLink, Menu, Tabs, Breadcrumb
OverlayLayer above contentModal, Drawer, Popover, Tooltip
LayoutStructureStack, Container, Divider

Naming conventions

Token: {category}-{property}-{variant}-{state} e.g. color-primary-500-hover, spacing-md
Component: {ComponentName} / {componentName}{Variant} e.g. Button, ButtonPrimary, ButtonOutline
CSS (BEM): .block__element--modifier e.g. .button__icon--loading

File structure

components/
├── Button/
│ ├── Button.tsx # Main component
│ ├── Button.css # Co-located styles (see css/architecture.md)
│ ├── Button.test.tsx # Tests
│ ├── Button.stories.tsx # Storybook
│ ├── Button.types.ts # TypeScript types
│ └── index.ts # Export

Props interface pattern

interface ButtonProps {
/** Visual variant of the button */
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
/** Size of the button */
size?: 'sm' | 'md' | 'lg';
/** Whether button is disabled */
disabled?: boolean;
/** Whether button shows loading state */
loading?: boolean;
/** Left icon element */
leftIcon?: React.ReactNode;
/** Click handler */
onClick?: () => void;
/** Button content */
children: React.ReactNode;
}

Component checklist before release

  • All sizes implemented (sm, md, lg); all variants implemented (primary, secondary, etc.)
  • All states working (hover, active, focus, disabled, loading)
  • Uses only design tokens (no hardcoded values); TypeScript types complete
  • Storybook stories for all variants; unit tests passing
  • Correct semantic HTML element; ARIA attributes where needed
  • Visible focus indicator; color contrast meets AA; keyboard-only operable
  • Touch target ≥ 44×44px

Responsive and fluid calculations

Breakpoints

NameMin widthTarget devices
xs0Small phones
sm480pxLarge phones
md640pxSmall tablets
lg768pxTablets, small laptops
xl1024pxLaptops, desktops
2xl1280pxLarge desktops
3xl1536pxExtra large displays
/* Mobile-first: base styles are mobile, media queries layer up */
.component { padding: var(--space-2); }
@media (min-width: 768px) { .component { padding: var(--space-6); } }

Fluid typography (clamp)

font-size: clamp(min, preferred, max);
/* 16px to 24px between 320px and 1200px viewport */
font-size: clamp(1rem, 0.5rem + 2vw, 1.5rem);
StyleMobile (320px)Desktop (1200px)Clamp
h132px64pxclamp(2rem, 1rem + 3.6vw, 4rem)
h228px48pxclamp(1.75rem, 1rem + 2.3vw, 3rem)
body16px18pxclamp(1rem, 0.95rem + 0.2vw, 1.125rem)

Fluid spacing

--space-section: clamp(3rem, 2rem + 4vw, 7.5rem);
--space-component: clamp(1rem, 0.5rem + 1vw, 2rem);

Container queries

.card-container { container-type: inline-size; container-name: card; }
@container card (min-width: 400px) {
.card { display: flex; flex-direction: row; }
}

Grid systems

12-column grid (grid-template-columns: repeat(12, 1fr), .col-{n} { grid-column: span n; }) and auto-fit card grids (grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));) cover most layouts without a bespoke framework.

Developer and design-tool handoff

Export formats

Three formats cover practically every consumer:

  • JSON — Figma plugins, Storybook, JS/TS projects, design-tool APIs.
  • CSS custom properties — plain CSS, CSS-in-JS, any web project; the format this stack uses by default.
  • SCSS variables — SASS pipelines, component libraries, theming maps.
design-tokens.css
:root {
--color-primary-500: #0066CC;
--font-size-base: 16px;
--spacing-4: 16px;
}

Tailwind wiring

// tailwind.config.js — reference the token file, never copy values into it
const tokens = require('./design-tokens.json');
module.exports = {
theme: {
extend: {
colors: tokens.colors,
fontFamily: { sans: [tokens.typography.fontFamily.sans] },
spacing: tokens.spacing,
borderRadius: tokens.borders.radius,
},
},
};

Figma sync

Tokens Studio plugin imports design-tokens.json directly; Figma Variables (native) can also import via plugin or API. Sync path: design_token_generator.pydesign-tokens.json → Tokens Studio → Figma Styles & Variables.

Handoff checklist

  • Brand color + style selected; tokens generated and all formats exported (JSON, CSS, SCSS)
  • Token files added to project; build pipeline configured; hot reload working for token changes
  • Figma/design tool updated; component library aligned; Storybook stories created
  • Colors render correctly; typography scales properly; spacing matches design; dark mode tokens present (if applicable)

Breaking-change policy

Change typeVersion bumpMigration
Add new tokenPatch (1.0.x)None
Change token valueMinor (1.x.0)Optional
Rename/remove tokenMajor (x.0.0)Required