Design Tokens
The design token system provides a structured way to define, organize, and override visual properties (colors, spacing, typography, layout) across the theme. It uses a three-layer architecture with clear ownership boundaries so core framework tokens and site-specific overrides coexist without conflict.
For the complete token inventory, see the CSS source files in src/core/styles/ and src/site/styles/. This reference documents the system — how it works, how to extend it, and what rules to follow.
Who this is for
- Implementors building or modifying CSS token files
- Developers customizing a site’s visual identity via theme overrides
- AI agents generating component styles that consume tokens
Three-Layer Model
Every design token belongs to one of three layers:
| Layer | Prefix | Purpose | Example |
|---|---|---|---|
| Primitive | --pt-* | Baseline values — scales, sizes, brand hues | --pt-space-md, --pt-text-lg, --pt-color-brand-primary |
| Semantic | --st-* | Contextual aliases that map to primitives | --st-color-brand-primary, --st-font-family-body, --st-font-size-h1 |
| Tailwind Bridge | @theme block | Registers tokens as Tailwind utility classes | --color-brand-primary: var(--st-color-brand-primary) |
How they connect: Components consume semantic tokens (--st-*) for colors — never --pt-color-* or raw color literals. For non-color properties (spacing, typography scale, etc.), components may reference --pt-* primitives directly. The bridge layer enables Tailwind utility classes to resolve to the project’s token values.
When to create semantic tokens
Semantic tokens for non-color properties are created when:
- A value is used repeatedly across multiple components (e.g., section spacing)
- A value represents an obvious client-override point (e.g., heading sizes, button padding)
This is a review-time judgment call, not an automated rule. The semantic inventory grows organically as components reveal patterns.
Token Categories
Tokens are organized into six categories. Each is documented with representative samples — see the CSS source files for the complete inventory.
Color Tokens
Color primitives provide the override surface for site identity. Four groups:
- Absolutes:
--pt-color-black,--pt-color-white,--pt-color-transparent— fixed values for overlays, shadows, and on-inverted text. - Neutral ramp:
--pt-color-neutral-{50–950}(11 stops) — maps to Tailwindslateby default. Override this ramp to swap the neutral family (e.g., slate → zinc). - Status:
--pt-color-status-{success,warning,error,info}+-muted(8 tokens) — maps to Tailwind green/amber/red/blue. - Brand:
--pt-color-brand-{primary,secondary,tertiary}+-emphasis,-subtle(9 tokens) — maps to Tailwind blue/teal/purple.
Semantic color tokens are grouped by purpose:
| Group | Example tokens | Purpose |
|---|---|---|
| Brand fill | --st-color-brand-primary, -emphasis, -subtle | Brand identity colors used as fills |
| Brand fg | --st-color-brand-primary-fg, -secondary-fg, -tertiary-fg | Brand colors used as foregrounds on canvas |
| Surface | --st-color-bg-canvas, --st-color-surface-soft, -inverted | Background/container colors |
| Text | --st-color-text-default, -muted, -on-inverted, --st-color-on-brand-primary, --st-color-text-heading, -heading-on-inverted, -heading-on-contrast, -heading-on-brand-primary, -heading-on-brand-secondary, -heading-on-brand-tertiary | Foreground colors for various surfaces |
| Link | --st-color-link, -hover, -visited, -on-contrast, -on-inverted | Link colors, including on-surface variants |
| Action | --st-color-action-primary-bg, -fg, -border, plus secondary, tertiary, neutral, inverted, on-primary, on-secondary, and on-tertiary families | Button/CTA colors |
| Status | --st-color-status-success, -warning, -error, -info | Feedback/alert colors |
| Navigation | --st-color-nav-link, -link-hover, -link-active | Nav-specific colors |
| Form | --st-color-form-control-bg, -control-border, -placeholder | Form input colors |
| Utility | --st-color-focus-ring, --st-color-overlay-scrim, --st-color-mark-bg | Misc UI colors |
Every --st-color-* token defined in light mode must also be defined in dark mode.
Brand fill and foreground semantics are separate. Use --st-color-brand-primary, --st-color-brand-secondary, and --st-color-brand-tertiary when the brand color paints an area or decorative fill. Use --st-color-brand-primary-fg, --st-color-brand-secondary-fg, and --st-color-brand-tertiary-fg when the brand color itself must read as foreground on the canvas, including text, borders, outlines, accents, and native control chrome. In light mode the -fg tokens resolve to the brand base hues; in dark mode they resolve to lighter brand primitives so foreground contrast stays readable.
Heading color is a first-class semantic. --st-color-text-heading defaults to var(--st-color-brand-primary) so the override point is visible out of the box. Override it in src/site/styles/light.css and src/site/styles/dark.css to repaint every heading in the theme. The five -on-* variants (-on-inverted, -on-contrast, -on-brand-primary, -on-brand-secondary, -on-brand-tertiary) are not bridged as Tailwind utilities — instead, each .surface--bg-* class reassigns --st-color-text-heading locally so headings flow through the surface cascade automatically. Components mounted outside that cascade can still consume the raw custom property via Tailwind’s arbitrary-value syntax, e.g. text-(color:--st-color-text-heading-on-inverted).
Action token families follow the pattern --st-color-action-{family}-{role} where family is one of primary, secondary, tertiary, neutral, inverted, on-primary, on-secondary, or on-tertiary, and role is one of bg, fg, border, bg-active, or focus. Representative examples: --st-color-action-tertiary-bg, --st-color-action-neutral-bg, --st-color-action-on-tertiary-bg. Outline button variants (outline, secondary-outline, tertiary-outline) reuse their matching family tokens with transparent backgrounds rather than introducing separate outline token families. The Button primitive consumes each variant family’s dedicated -focus token through its internal CSS-variable plumbing so focus rings stay aligned with the active action color.
Overlay and nav-surface tokens are wired to the primitives that own them: --st-color-nav-dropdown-bg / --st-color-nav-dropdown-border in Dropdown, --st-color-nav-mobile-bg / --st-color-nav-mobile-overlay in Drawer, and --st-color-overlay-scrim in global overlay surfaces such as search and lightbox backdrops.
Typography Tokens
Primitive families (--pt-font-family-sans, -serif, -mono, -display) define the font stacks. Semantic roles (--st-font-family-body, -heading, -ui, -prose, -code) map to primitives so a site can remap roles without changing components.
Primitive type scale (--pt-text-xs through --pt-text-5xl) defines fluid clamp() values pre-computed by the build-time token generator from min–max rem ranges and viewport bounds defined in src/core/config/fluid-tokens.config.ts. See Fluid Token Generation below.
Semantic heading scale (--st-font-size-h6 through --st-font-size-h1, plus -display and -code) maps heading roles to the primitive scale. Base element styles consume these semantic tokens; Heading.astro also supports explicit primitive size tokens via its size prop ("xs" through "5xl").
Font loading: Managed by the Astro Fonts API. Core provides defaults and the FontEntry type in src/core/config/fonts.config.ts; sites customize via src/site/config/fonts.config.ts. The Google Fonts provider downloads and self-hosts variable .woff2 files at build time. font-display: swap for all faces. System fallbacks in every stack. Metric-adjusted fallback faces generated automatically. Default shipped trio: Open Sans (sans), Source Serif 4 (serif), JetBrains Mono (mono).
Spacing Tokens
Primitive scale (--pt-space-0 through --pt-space-5xl) uses fluid clamp() values pre-computed by the build-time token generator. Components reference these directly: padding: var(--pt-space-md). See Fluid Token Generation below.
Semantic layout gaps (--st-layout-block-gap-sm through -xl) provide section-level spacing tokens that map to the primitive scale. These are the primary override point for adjusting vertical rhythm across a site.
Layout & Container Tokens
Content width constraints, container cap, gutter, sidebar dimensions, and breakpoints:
| Token | Value | Purpose |
|---|---|---|
--pt-layout-content-max | 65ch | Narrow content (articles, docs) |
--pt-layout-content-wide-max | 80ch | Wider content (landing pages) |
--pt-layout-container-min | 20rem | Container minimum for fluid clamp interpolation |
--pt-layout-container-max | 80rem | Site-wide container cap |
--pt-layout-container-full | 100% | Full-width layouts |
Fluid Token Generation
Fluid spacing and typography tokens use clamp() values that scale linearly between a min and max viewport width. These values are pre-computed at build time by scripts/generate-fluid-tokens.ts rather than using runtime calc() division — Firefox Bug 1827404 prevents <length> / <length> in calc() with custom properties. (D-FT-01)
Source of truth: src/core/config/fluid-tokens.config.ts defines the viewport bounds and all token scales:
| Config key | Default | Purpose |
|---|---|---|
vwMin | 20 (320px) | Smallest viewport for fluid interpolation |
vwMax | 90 (1440px) | Largest viewport for fluid interpolation |
spacing[] | 11 entries (3xs–5xl) | Min/max rem pairs for each spacing step |
typography[] | 9 entries (xs–5xl) | Min/max rem pairs for each type step |
custom[] | 1 entry | Arbitrary fluid tokens with explicit prop names |
Site-level overrides go in src/site/config/fluid-tokens.config.ts — see Site Configuration for the override workflow.
Generated output: src/core/styles/fluid-tokens.css (core tokens) and src/site/styles/fluid-tokens.css (site override tokens, if any). Both files are committed but auto-generated — do not edit them directly.
Workflow: Edit the config → run npm run generate:tokens → commit the updated CSS. The validate pipeline includes generate:tokens:check which fails if generated CSS is out of sync with config.
The generated clamp() formula for each token:
--pt-space-md: clamp(MIN, interceptRem + slopeVw, MAX);
Where slope = (max − min) / (vwMax − vwMin) and intercept = min − slope × vwMin, fully resolved to numeric values at build time.
Breakpoint reference: CSS custom properties cannot be used in @media queries, so breakpoints are not tokenized. Use Tailwind’s responsive prefixes (sm:, md:, lg:, xl:, 2xl:) for responsive styles. The project uses Tailwind v4’s default breakpoints:
| Prefix | Min-width | Typical use |
|---|---|---|
sm | 640px | Large phones / landscape |
md | 768px | Tablets |
lg | 1024px | Laptops / small desktops |
xl | 1280px | Desktops |
2xl | 1536px | Large monitors |
Z-Index Layer Tokens (REQ-00189)
Layered UI contexts (dropdowns, overlays, modals, sticky headers) use semantic --st-layer-* tokens rather than ad-hoc numeric z-index values. This follows the same three-layer pattern as other token categories: primitives define the numeric scale, semantic tokens express UI intent, and the Tailwind bridge exposes utilities for component markup.
Naming convention: Semantic layer tokens use the --st-layer-{role} pattern, where {role} describes the UI purpose — not a numeric rank. Bridge utilities follow the z-{role} pattern. Examples:
--st-layer-sticky→ bridge utilityz-sticky(sticky header, sidebar columns)--st-layer-dropdown→ bridge utilityz-dropdown(menus, listbox panels)--st-layer-modal-surface→ bridge utilityz-modal-surface(modal foreground surfaces)
Layer ordering model: Layers are ordered from lowest (page chrome like sticky headers) to highest (modal surfaces and toasts). The complete ordered inventory and its primitive mappings live in src/core/styles/semantic.css — that file is the authoritative source for the current layer set.
Backdrop/surface pairs: Overlay-style patterns that render both a backdrop and a foreground surface use explicit named layer pairs (e.g., z-modal-backdrop + z-modal-surface) rather than local arithmetic like calc(var(...) + 1). Each category that needs this pairing gets two tokens: *-backdrop and *-surface.
Consumption rule: Components use bridge utilities (z-dropdown, z-modal-surface, etc.) in markup. Direct var(--st-layer-*) references are allowed only in scoped <style> blocks where Tailwind cannot express the value cleanly. Direct primitive --pt-z-* references are not allowed in component code.
Site overrides: Sites can remap layer ordering by overriding the semantic tokens in src/site/styles/semantic.css. The primitive numeric scale remains in src/core/styles/primitives.css.
Component Sizing Tokens
Tokens for component-specific dimensions that don’t belong to a general scale:
--pt-size-nav-touch-target(2.75rem) — minimum touch target for nav items--pt-size-nav-dropdown-min-width(8rem) — dropdown menu minimum width--pt-size-logo-header-width— header logo display width (fluid custom token, see Fluid Token Generation)--pt-size-logo-footer-width(12rem) — footer logo display width
Interaction Tokens
Duration (--pt-duration-fast, -normal, -slow), easing (--pt-ease-standard), and a --pt-motion-reduce-factor that drops to 0 under prefers-reduced-motion.
Theme Mode Switching
Light/dark mode uses a hybrid approach: data-theme attribute for persisted user choice, with prefers-color-scheme as initial fallback.
Selector convention:
:root, :root[data-theme="light"]— light mode tokens (default):root[data-theme="dark"]— dark mode tokens- JS resolves system preference at load time → sets
data-theme
Only color semantic tokens (--st-color-*) differ between modes. Non-color tokens (spacing, typography, layout) are mode-independent and defined once in semantic.css.
File Organization
src/
├── core/
│ ├── config/
│ │ └── fluid-tokens.config.ts # Fluid token definitions — source of truth (CORE-OWNED)
│ └── styles/
│ ├── global.css # Entry point: @import order only (CORE-OWNED)
│ ├── fluid-tokens.css # Generated fluid clamp() tokens — do not edit (CORE-OWNED)
│ ├── primitives.css # --pt-* raw values (CORE-OWNED)
│ ├── semantic.css # --st-* mode-independent (CORE-OWNED)
│ ├── light.css # --st-color-* light mode (CORE-OWNED)
│ ├── dark.css # --st-color-* dark mode (CORE-OWNED)
│ ├── base.css # Element-level styles (CORE-OWNED)
│ ├── components.css # Shared component classes (CORE-OWNED)
│ ├── starwind-bridge.css # Optional Starwind component-system bridge (CORE-OWNED)
│ └── bridge.css # Project @theme block (CORE-OWNED)
├── site/
│ ├── config/
│ │ └── fluid-tokens.config.ts # Fluid token overrides (SITE-OWNED)
│ └── styles/
│ ├── fluid-tokens.css # Generated site override tokens — do not edit (SITE-OWNED)
│ ├── primitives.css # --pt-* site overrides (SITE-OWNED)
│ ├── semantic.css # --st-* site overrides (SITE-OWNED)
│ ├── light.css # --st-color-* light overrides (SITE-OWNED)
│ ├── dark.css # --st-color-* dark overrides (SITE-OWNED)
│ ├── base.css # Element-level overrides (SITE-OWNED)
│ ├── components.css # Shared component class overrides (SITE-OWNED)
│ ├── starwind-bridge.css # Optional Starwind bridge activation/overrides (SITE-OWNED)
│ └── bridge.css # Project @theme overrides and extensions (SITE-OWNED)
└── scripts/
└── generate-fluid-tokens.ts # Build-time token generator (CORE-OWNED)
Override precedence: core fluid-tokens → core primitives → core semantic → core light/dark → site fluid-tokens → site primitives → site semantic → site light/dark → core base → site base → core components → site components → site Starwind bridge (optional activation surface) → core bridge → site bridge. Site files override core by CSS cascade where paired layers load core first and site second. The Starwind bridge remains opt-in: src/site/styles/starwind-bridge.css loads by default as an empty surface, and sites import src/core/styles/starwind-bridge.css there only when they use Starwind components. Fluid token files are generated — see Fluid Token Generation.
Customizing a Site’s Tokens
Site owners override tokens in src/site/styles/. The pattern is the same as CSS custom property overrides — redeclare the token with a new value:
/* src/site/styles/primitives.css (SITE-OWNED) */
:root {
--pt-color-brand-primary: oklch(0.55 0.2 250);
--pt-color-brand-primary-emphasis: oklch(0.45 0.22 250);
--pt-color-brand-primary-subtle: oklch(0.75 0.15 250);
}
Only override what differs. Unset tokens inherit the core defaults.
Lint Rules
The lint:tokens validation step enforces these rules:
Color enforcement (strict):
- No raw color literals (
#hex,rgb(),hsl(),oklch()) in component/layout files. - No
--pt-color-*references in component/layout files. Use--st-color-*or Tailwind color utilities.
Non-color primitives (allowed in components):
--pt-space-*,--pt-text-*, etc. may be referenced directly. These are not lint errors.
Structural rules: 3. No --st-* or --pt-* definitions outside src/core/styles/ and src/site/styles/. 4. Every --st-color-* in light.css must also appear in dark.css (and vice versa). 5. Every token intended for Tailwind consumption must appear in the @theme bridge block.