Skip to main content
Urbicon UI

Design Tokens

A comprehensive design token system built with CSS custom properties and Tailwind 4. Organized in semantic layers for consistent theming and easy customization.

Token Architecture

Foundation Layer

Base design decisions like colors, spacing, and typography scales.

Raw Values

Semantic Layer

Intent-based tokens that map foundation tokens to semantic meanings.

Contextual

Interaction Layer

Motion, shadow, and focus tokens for consistent animations and depth.

Motion & Depth

Foundation Colors

@theme {
  /* Foundation Colors (oklch for better perception) */
  --color-neutral-50: oklch(0.98 0.005 240);
  --color-neutral-500: oklch(0.55 0.016 240);
  --color-neutral-900: oklch(0.15 0.012 240);

  /* Intent 500s darkened so white text passes WCAG AA */
  --color-primary-500: oklch(0.58 0.15 240);
  --color-success-500: oklch(0.5 0.15 140);
  --color-danger-500: oklch(0.5 0.17 25);
}

Semantic Tokens

/* Semantic Layer – maps foundation to
   roles; light-dark() resolves both modes */
--color-surface-base: light-dark(
  var(--color-neutral-0), var(--color-neutral-900));
--color-text-primary: light-dark(
  var(--color-neutral-900), var(--color-neutral-100));
--color-primary: light-dark(
  var(--color-primary-600), var(--color-primary-500));

/* Interactive states */
--color-interactive-hover:
  oklch(from var(--color-primary-500) l c h / 0.1);
--color-interactive-focus: light-dark(
  var(--color-primary-500), var(--color-primary-400));

Interaction Layer

/* Interaction Layer – motion & depth */
--blocks-duration-fast: 150ms;
--blocks-duration-normal: 250ms;
--blocks-ease-gentle: cubic-bezier(0.25, 0.1, 0.25, 1);

--blocks-shadow-sm: var(--color-shadow-sm);
--blocks-shadow-md: var(--color-shadow-md);

Color System

Neutral Palette

The neutral ramp has 16 steps: alongside the standard 50–950 ladder it ships a 25 tint (the quiet-surface ground) and the half-steps 650/750/850, which differentiate the dark-mode elevation ladder.

0
25
50
100
200
300
400
500
600
650
700
750
800
850
900
950

Intent Ramps

Six intent ramps — primary, secondary, success, warning, danger, info — each a full 50–950 ladder. The 500/600/700 stops are tuned dark enough that white text-on-fill passes WCAG AA on the solid fills — that is the label colour for every solid intent, which is why it is not named after one of them. text-on-primary still exists, resolves to the same value and governs the primary fill alone, so retuning it cannot repaint success or danger along with it. Warning is the deliberate exception (its fill stays light in both modes and pairs with its own warm-dark text-on-warning). A separate warm-neutral ramp powers the themeable neutral intent chrome.

primary

--color-primary-50
--color-primary-500
--color-primary-900

secondary

--color-secondary-50
--color-secondary-500
--color-secondary-900

success

--color-success-50
--color-success-500
--color-success-900

warning

--color-warning-50
--color-warning-500
--color-warning-900

danger

--color-danger-50
--color-danger-500
--color-danger-900

info

--color-info-50
--color-info-500
--color-info-900

Spacing Scale

Urbicon UI does not ship a custom spacing token layer — it uses Tailwind's built-in spacing scale directly. Apply spacing with the standard utilities (p-4, gap-2, m-6). Each step is 0.25rem × n, driven by Tailwind's own --spacing variable.

UtilityValuePixelsVisual
p-0 / gap-000px
p-1 / gap-10.25rem4px
p-2 / gap-20.5rem8px
p-3 / gap-30.75rem12px
p-4 / gap-41rem16px
p-6 / gap-61.5rem24px
p-8 / gap-82rem32px
p-12 / gap-123rem48px

Typography Scale

Type is themeable exactly like color. Sizes, weights, leading, tracking and font families are Tailwind @theme variables — --text-sm, --font-weight-medium, --leading-tight, --tracking-wide, --font-sans, --font-mono — and you override them in the same @theme block that retunes --color-primary-500. This docs site is the proof: it rethemes the whole library's type by overriding --font-mono and --font-sans — see apps/docs/src/lib/style/rooms-docs.css.

Two things to get right.
  • Change the paired line-height too. Tailwind's built-in sizes each ship a companion --text-*--line-height. Resize without it and the rhythm goes subtly wrong everywhere the size is used. The two library-added steps (--text-2xs, --text-3xs) are deliberately size-only — Tailwind emits a line-height only when the paired key exists, so they inherit it from the cascade. Add the paired key in your own @theme if you want one.
  • This works because of one property: the library deliberately does not @import 'tailwindcss', so there is exactly one Tailwind compilation — yours — and it wins. If your tooling introduces a second one, typography overrides silently revert, exactly like color overrides do. See docs/TailwindCaveats.md.

Theme the type scale

/* app.css — the SAME @theme block that retunes color.
   Safe because the library never re-imports Tailwind: your
   @theme is compiled last and wins. */
@import 'tailwindcss';
@import '@urbicon-ui/blocks/style/index.css';

@theme {
  /* Families — blocks never sets `font-sans`, so body type simply
     inherits from your page. It DOES use `font-mono` (CommandPalette
     shortcut keys, JourneyTimeline meta), so this retunes those. */
  --font-sans: 'Inter Variable', system-ui, sans-serif;
  --font-mono: 'JetBrains Mono', ui-monospace, monospace;

  /* Size AND its paired line-height. Tailwind's built-in sizes each
     ship a --text-*--line-height; changing the size alone leaves the
     old rhythm behind on all ~128 text-sm call sites. */
  --text-sm: 0.9375rem;
  --text-sm--line-height: calc(1.375 / 0.9375);

  --font-weight-medium: 550;
  --leading-tight: 1.3;
  --tracking-wide: 0.02em;
}

Know where the leverage is. The library's usage is steep and lopsided: --text-sm reaches the most call sites by a wide margin, while nothing above text-xl is used at all — so overriding --text-6xl changes nothing in the library, and --text-sm reshapes it. And because blocks never sets font-sans, body type inherits from your page: you already own the font decision — no override needed.

UtilityOverrideValuePixelsUses in blocksExample
text-3xs--text-3xs0.625rem10px13The quick brown fox
text-2xs--text-2xs0.6875rem11px18The quick brown fox
text-xs--text-xs0.75rem12px118The quick brown fox
text-sm--text-sm0.875rem14px163The quick brown fox
text-base--text-base1rem16px91The quick brown fox
text-lg--text-lg1.125rem18px39The quick brown fox
text-xl--text-xl1.25rem20px14The quick brown fox
text-2xl--text-2xl1.5rem24px5The quick brown fox

Weights

UtilityOverrideValueUses in blocks
font-normal--font-weight-normal4005
font-medium--font-weight-medium50066
font-semibold--font-weight-semibold60051
font-bold--font-weight-bold70012

Border Radius

Physical Scale

Real --radius-* CSS variables, each with a matching Tailwind utility.

rounded-xs
0.125rem
rounded-sm
0.25rem
rounded-md
0.375rem
rounded-lg
0.5rem
rounded-xl
0.75rem
rounded-2xl
1rem
rounded-3xl
1.5rem
rounded-4xl
2rem

Semantic Tiers

Components consume a 3-tier semantic vocabulary, not raw radii. Re-tint the three tier variables to reshape the whole library at once — see the Tier System for the full cascade. A fourth token, --radius-bridge, is not a tier — it pairs pill triggers with their dropdown panels.

TokenValueUsed by
--radius-commit9999pxPill/round — actions, identity, status (Button, Badge, Toggle)
--radius-control9999pxRadio indicator only. Defaults to the same pill as --radius-commit but is not tied to it, so squaring your buttons keeps the "pick exactly one" circle. Set it to 0 if you want square radios too.
--radius-modifyvar(--radius-sm) · 4pxEditable surfaces, navigation (Input, Select, Tab, Menu)
--radius-containvar(--radius-xs) · 2pxContainers, panels (Card, Alert, Dialog, Drawer, Popover, Tooltip)
--radius-bridgevar(--radius-md) · 6pxThe middle rung, for two cases: adjacency (a floating panel anchored to a pill trigger — the Menu panel) and optical size (a surface too small for the container radius to read as intentional — the ChatMessage bubble, Textarea at tier="commit", Card tier="bridge")

Motion & Depth

The interaction layer ships real --blocks-duration-* and --blocks-shadow-* variables (plus easing curves like --blocks-ease-gentle). Durations collapse to 1ms under prefers-reduced-motion; prefers-contrast: more widens the focus ring to 3px and promotes hairline borders; in print, shadows drop to none.

Duration tokenValue
--blocks-duration-instant75ms
--blocks-duration-fast150ms
--blocks-duration-normal250ms
--blocks-duration-slow350ms
--blocks-duration-slower500ms
--blocks-duration-slowest750ms
Shadow tokenSource
--blocks-shadow-xsvar(--color-shadow-xs)
--blocks-shadow-smvar(--color-shadow-sm)
--blocks-shadow-basevar(--color-shadow-base)
--blocks-shadow-mdvar(--color-shadow-md)
--blocks-shadow-lgvar(--color-shadow-lg)
--blocks-shadow-tint0 0 0 · oklch L C H, no alpha

Theme-level chroma knobs

Two tokens let a theme match its chrome to its chassis without touching contrast. The four coloured themes set both — see blocks/src/lib/style/themes/forest.css. The Neutral theme sets neither, by design: it inherits the library-default cool grey chrome and leaves the shadow tint untouched.

TokenDefaultWhat it does
--blocks-shadow-tint0 0 0oklch L C H triplet (no alpha) spliced into every --color-shadow-*. Re-tint so shadows match your chassis instead of reading as cool smudges.
--neutral-chrome-hue240Hue of the neutral intent chrome (bg-neutral / text-neutral / neutral borders). Keeps the warm-neutral ramp lightness — only the hue moves, so contrast is untouched.
Set these in :root, never in @theme. Both are raw partial values, not standalone tokens — --blocks-shadow-tint is an oklch L C H triplet without an alpha channel, spliced into oklch(var(--blocks-shadow-tint) / 0.05). Putting either inside @theme gets you nothing. The shipped themes declare them in a :root block after their @theme for exactly this reason.

Custom Theming

Use Tailwind 4's @theme directive to override tokens, or import one of the shipped themes — see Themes for the full gallery.

Custom Theme Example

@import '@urbicon-ui/blocks/style/index.css';

/* Option A — import a shipped theme
   (neutral, ocean, forest, rose, sunset) */
@import '@urbicon-ui/blocks/style/themes/ocean.css';

/* Option B — re-tint the ramps yourself. The semantic layer
   consumes several stops (600/500 base, 700/400 hover,
   800/300 active, 900/200 emphasis, 50/900 subtle), so
   override the WHOLE ramp: keep each stop's lightness and
   chroma, swap only the hue. */
@theme {
  --color-primary-500: oklch(0.58 0.15 280);
  --color-primary-600: oklch(0.52 0.15 280);
  --color-primary-700: oklch(0.44 0.13 280);
  /* … all stops 50–950 with the new hue */

  /* The chassis, NOT optional: surface/text/border derive
     from neutral, so a purple brand on the default cool
     240 chassis reads broken. Same L/C, hue → 290. */
  --color-neutral-50: oklch(0.98 0.005 290);
  --color-neutral-500: oklch(0.55 0.016 290);
  --color-neutral-900: oklch(0.15 0.012 290);
  /* … all 15 stops (25–950) with the new hue. Leave
     --color-neutral-0 (pure white) alone — tinting it
     tints your white. */
}

/* Raw partial values — :root, never @theme (see Motion & Depth). */
:root {
  --blocks-shadow-tint: 0.2 0.025 290;
  --neutral-chrome-hue: 290;
}

/* Custom brand tokens get Tailwind utilities for free
   (bg-brand-500, text-brand-500, …) */
@theme {
  --color-brand-500: oklch(0.5 0.25 45);
}

Override at the right layer

  • Re-tint a hue → foundation ramp. Override every stop of the ramp, keeping each stop's lightness/chroma profile — the WCAG-tuned contrast survives, and the semantic layer picks the new hue up in both modes automatically. This is exactly what the shipped themes do.
  • Change a role → semantic token. To alter what "elevated" or "subtle" means, override the semantic token itself — and supply a light-dark(light, dark) pair, otherwise the token is pinned to one look in both modes.
  • Restyle one component → the component API, not CSS. Use the override ladder: classslotClassesBlocksProvider presets/overrides → unstyled. Never force colors with ! overrides — see BlocksProvider.

Dark Mode Support

Design tokens automatically adapt to dark mode using light-dark(): :root declares color-scheme: light dark, so the browser resolves the matching branch from the user's preference. A manual toggle only sets :root.light / :root.dark to override the color-scheme — no token duplication, and no manual dark: overrides needed.

Light Mode Tokens

--color-surface-base neutral-0
--color-text-primary neutral-900

Dark Mode Tokens

--color-surface-base neutral-900
--color-text-primary neutral-100