A CSS custom property design token system with a layered resolution pipeline, React component library, and Storybook documentation.
yarn install
yarn storybook # dev server at localhost:6006
yarn dev # Vite dev app
yarn build-storybook # static Storybook buildTokens are loaded in strict dependency order via css/index.css. Each layer builds on the one below it — never skip layers or import out of order.
01-primitives Raw hex values and unitless numbers. No references.
02-theme Light / dark palette direction. Sets --color-* scale.
03-sub-styles Family selectors: eclipse, onyx, echo, nebula, cosmic, etc.
04-style Resolves --style-* unified scale (---style-00 → ---style-12).
05-priority-toggle Maps interaction states (default/hover/focus/active) to style steps.
06-status Activates the correct state via CSS pseudo-classes or data-status.
07-semantic Component-ready names: --surface-priority, --border-priority, etc.
── spacing Gap, padding, border-radius tokens.
── typography Font family, size, weight.
── size Component dimension tokens (--size-button-y, --size-button-x, etc.)
── device-grid Responsive breakpoints.
── flags Boolean state tokens.
Interactive (buttons, toggles, inputs):
theme → substyle → style → priority-toggle → status → semantic
Consumed as var(--surface-priority), var(--border-priority), var(--content-priority).
Non-interactive (static surfaces, text, dividers):
theme → substyle → style → semantic
Consumed as var(--surface-01), var(--content-02), etc.
Every component sets these on its root element to wire the token chain.
| Attribute | Values | Notes |
|---|---|---|
data-theme |
light | dark |
Omit to inherit from parent |
data-semantic |
theme | inverse |
Always "theme" unless explicitly inverting |
data-style |
cta | grey | alert | brand |
Selects the color family |
data-style |
Substyle attribute | Valid values |
|---|---|---|
cta |
data-cta |
nebula | cosmic |
grey |
data-grey |
eclipse | onyx | echo |
alert |
data-alert |
error | info | warning | success |
brand |
data-brand |
jupiter | uranus | neptune | saturn |
| Attribute | Values | Notes |
|---|---|---|
data-priority |
primary | secondary | tertiary |
Selects which style steps map to each state |
data-toggle |
selected | unselected |
Alternative to priority for chip/toggle components |
data-interactive |
"priority" | "toggle" |
Required marker for pseudo-class CSS rules |
data-size |
component-specific | Drives dimension tokens |
data-status |
default | hover | focus | active | disabled |
Storybook forced states only |
In production, data-status is never set. The :hover, :focus-visible, :active, and :disabled pseudo-classes in 06-status.css handle all state changes automatically.
Interactive components use a 2 + 2 + 2 inset box-shadow ring to communicate state without any extra DOM elements. The Figma "interaction wrapper" frame maps directly to this CSS pattern.
box-shadow:
inset 0 0 0 var(--priority-ring-surface-spread) var(--priority-ring-border),
inset 0 0 0 var(--priority-ring-border-spread) var(--priority-ring-surface);CSS border (2px, --border-priority) ← outermost, rendered on top of inset shadows
inset layer 1 (2px, ring-border color) ← halo band just inside the border
inset layer 2 (4px spread, ring-surface) ← ring accent; only the 2–4px band is visible
button fill
| State | ring-surface-spread |
ring-border-spread |
Visible rings |
|---|---|---|---|
| default | 0 | 0 | None |
| hover | 2px | 0 | Border + halo |
| focus | 2px | 4px | Border + halo + ring |
| active | 2px | 4px | Border + halo + ring |
| disabled | 0 | 0 | None (opacity 0.4) |
Focus rings only appear on keyboard/assistive navigation, not mouse clicks. This is handled entirely in CSS — no JavaScript required.
--focus-ring-border must resolve to a real color value. The --num-0 primitive (0, a unitless number) is invalid as a color — using it causes the entire box-shadow declaration to be silently discarded. Use transparent or a style-scale value.
components/interactiveAttrs.ts is the single source of truth for assembling the data-* attribute set for any interactive component. Spread its return value directly onto a native element.
import { interactiveAttrs } from './interactiveAttrs';
<button
{...interactiveAttrs({
colorStyle: 'cta',
substyle: 'nebula',
priority: 'primary',
theme: 'dark', // omit to inherit
semantic: 'theme',
status: status, // Storybook only — undefined in production
size: 'base',
})}
disabled={disabled}
/>All future interactive components should call this helper rather than assembling data-* attributes manually.
background: var(--surface-priority);
border-color: var(--border-priority);
color: var(--content-priority);
opacity: var(--component-opacity); /* 1 = normal, 0.4 = disabled */background: var(--surface-01); /* 00 darkest → 04 most elevated */
border-color: var(--border-01); /* 00 strongest → 04 subtlest */
color: var(--content-01); /* 00 strongest → 04 subtlest */gap: var(--gap-xs); /* 4px */
gap: var(--gap-sm); /* 8px */
gap: var(--gap-md); /* 12px */
gap: var(--gap-lg); /* 16px */font-family: var(--text-family-manual-secondary, "Nunito Sans", sans-serif);
font-size: var(--text-size-body);Note:
--text-weight-mode-weightresolves to Figma variable font axis strings ("Normal","SemiBold"), not valid CSSfont-weightvalues. Use numeric weights (400, 600) instead.
<element
{...interactiveAttrs({ colorStyle, substyle, priority, theme, semantic, status, size })}
disabled={disabled}
style={{
background: 'var(--surface-priority)',
border: 'var(--priority-border-width) solid var(--border-priority)',
color: 'var(--content-priority)',
opacity: 'var(--component-opacity)',
boxShadow: [
'inset 0 0 0 var(--priority-ring-surface-spread) var(--priority-ring-border)',
'inset 0 0 0 var(--priority-ring-border-spread) var(--priority-ring-surface)',
].join(', '),
}}
/><element
data-theme={theme}
data-semantic="theme"
data-style="grey"
data-grey="eclipse"
style={{ background: 'var(--surface-01)', color: 'var(--content-01)' }}
/>Stories live in storybook/components/. Each component story exports:
- Playground — fully controllable via the Controls panel. Substyle options update automatically when
colorStylechanges (conditional controls viaargTypes.if). - States — forced states via
data-statusfor visual inspection. - Interaction Ring — side-by-side light/dark view showing all five states across all three priorities, demonstrating the inset ring system across surfaces.
- Priorities / Sizes / Icons / CTA / Grey / Alert — static reference grids.
data-theme is set on <html> by @storybook/addon-themes via withThemeByDataAttribute. The toolbar switcher toggles between Dark (default) and Light.
| Method | When to use |
|---|---|
data-status="focus" on element |
Storybook stories — pins a state for inspection |
CSS :focus-visible / :hover / :active |
Production — all real interactions, no JS needed |
tokens.ts exports constants for every CSS variable name so you never write a magic string.
import { surface, border, content, text, gap, rounded, borderWidth, dataAttr, token } from './tokens';
import type { Priority, Toggle, ColorStyle, Theme, Semantic, Size } from './tokens';
// Wrap a token name for inline styles
style={{
background: token(surface[1]), // 'var(--surface-01)'
color: token(content[2]), // 'var(--content-02)'
gap: token(gap.md), // 'var(--gap-md)'
borderRadius: token(rounded.sm), // 'var(--rounded-sm)'
}}
// Spread data attributes type-safely
<div {...dataAttr.theme('dark')} {...dataAttr.semantic('inverse')}>
// Constrain prop types
function MyButton({ priority }: { priority: Priority }) { … }
function MyChip({ toggle }: { toggle: Toggle }) { … }| Export | CSS variables covered |
|---|---|
surface |
--surface-00…04, --surface-priority, --surface-toggle |
border |
--border-00…04, --border-priority, --border-toggle |
content |
--content-00…04, --content-priority, --content-toggle |
base |
--base-00…04, --base-priority, --base-toggle |
interactive |
--interactive-surface/border/content/border-width |
priorityRing |
--priority-ring-border/surface/border-spread/surface-spread |
overlay |
--overlay-muted |
coreColors |
--semantic-core-jupiter/saturn/neptune/uranus/cosmic/nebula |
borderWidth |
--border-width-none/2xs/xs/sm/sm-base/base/md-base/md/lg/xl/2xl/3xl |
rounded |
--rounded-full/none/3xs/2xs/xs/sm/base/md/lg/xl/2xl/3xl |
padding |
--padding-none/3xs/2xs/xs/sm/base-sm/base/…/8xl |
gap |
--gap-0/2xs/xs/sm/md/lg/xl/2xl/…/9xl |
text.size |
--text-size-3xs … --text-size-7xl |
text.family |
manual font-family vars |
iconSize |
--size-icon-3xs … --size-icon-4xl |
componentSize |
--size-avatar, --size-button-text/icon/x/y, --size-tags-*, --size-pill-* |
opacity |
--component-opacity |
dataAttr.theme('dark') // → { 'data-theme': 'dark' }
dataAttr.priority('primary') // → { 'data-priority': 'primary' }
dataAttr.toggle('selected') // → { 'data-toggle': 'selected' }
dataAttr.size('lg') // → { 'data-size': 'lg' }
dataAttr.disabled(true) // → { 'data-disabled': 'true' }
dataAttr.expanded(false) // → { 'data-expanded': 'false' }All components re-exported from a single barrel file:
// Default component imports
import Button from './components/Button';
import { default as Table, type TableColumn } from './components/Table';
// Or via the barrel
import { Button, Table, type TableColumn, type TokenOptions } from './components';TokenOptions is exported once from the barrel (from interactiveAttrs) — import it from here rather than from individual component files.
| Export | Description |
|---|---|
Icon |
Phosphor icon wrapper — sizes via --size-icon-* |
Button |
Full priority pipeline — copy this pattern for other interactive components |
Card |
Semantic-surface layout panel |
Identity / media: Avatar, AvatarBlock, AvatarGroup, Logo
Labels: Badge, BadgeNotification, Tag, Pill
Navigation: Link, Navigation, Sidebar, Tabs, Pagination
Forms: Checkbox, RadioButton, Switch, Slider, InteractionButton, TextInput, TextArea, SearchBar, DatePicker, Upload
Selection: SingleSelect, Multiselect
Data display: Table, Lists, Accordion, ProgressBar
Feedback / overlays: Toast, Notifications, Dialog, Popover, Tooltip
Motion: Carousel
Layout: Scrollbar, ScrollArea, Separator, Footer, Sections, PageLayout, HomePage, DashboardPage
import type {
TokenOptions, // interactiveAttrs input shape
AccordionItem, // { id, label, content, disabled? }
ListItem, // { id, label, meta?, icon?, disabled? }
NavigationItem, // { id, label, href?, icon?, disabled? }
NotificationItem, // { id, title, message?, timestamp?, read?, actions? }
SelectOption, // { value, label, disabled? }
SidebarItem, // { id, label, href?, icon?, disabled?, children? }
TabItem, // { id, label, content, disabled? }
TableColumn, // { key, header, accessor, sortable?, width? }
} from './components';Each stub file contains everything needed except the JSX. The pattern:
- Read the
TODOcomment — it lists exactly which pipeline and tokens to consume. - Replace
return nullwith your JSX. - For interactive components, spread
interactiveAttrs(props)on the interactive element. - Reference
--interactive-*tokens in CSS for the element's own colours;--surface-*/--content-*/--border-*for non-interactive children.
Toggle pipeline example (Tabs):
export default function Tabs(props: TabsProps) {
const { items = [], activeId, theme, semantic } = props;
return (
<div data-theme={theme} data-semantic={semantic ?? 'theme'}>
{items.map(item => (
<button
key={item.id}
{...interactiveAttrs({
colorStyle: props.colorStyle ?? 'cta',
substyle: (props as any).substyle,
toggle: item.id === activeId ? 'selected' : 'unselected',
theme,
semantic,
})}
>
{item.label}
</button>
))}
</div>
);
}Priority pipeline example (ProgressBar):
export default function ProgressBar(props: ProgressBarProps) {
const { value = 0, max = 100, priority = 'primary', theme, semantic } = props;
const pct = Math.min(100, Math.max(0, (value / max) * 100));
return (
<div data-theme={theme} data-semantic={semantic ?? 'theme'}>
<div
{...interactiveAttrs({ colorStyle: props.colorStyle ?? 'cta', priority, theme, semantic })}
style={{ width: `${pct}%` }}
/>
</div>
);
}// app entry point
import 'path/to/tokens-claude/tokens.css';<html data-theme="dark"> <!-- default; use "light" for light mode -->// vite.config.ts
resolve: { alias: { '@ds': '/path/to/tokens-claude' } }import Button from '@ds/components/Button';
import { surface, token } from '@ds/tokens';To wire an existing component into the token pipeline:
- Remove hardcoded hex colours — replace with
var(--surface-*),var(--content-*),var(--border-*). - For interactive elements, call
interactiveAttrsand replace manual:hover/:focusCSS withvar(--interactive-*). - Remove hand-rolled state blocks in CSS — layers 05–06 handle them automatically via pseudo-classes.
- Ensure an ancestor carries
data-themeanddata-semantic.
Any element can create a new token context by setting a data-* attribute — the CSS cascade resolves everything downstream:
<!-- Dark page with a light-themed card inside it -->
<html data-theme="dark">
<div class="card" data-theme="light">…</div>
</html>
<!-- Grey scheme with a CTA button inside it -->
<section data-style="grey" data-grey="eclipse">
<button data-style="cta" data-cta="nebula" data-priority="primary">…</button>
</section>
<!-- Inverse tooltip over a light surface -->
<div class="tooltip" data-semantic="inverse">…</div>No JavaScript is needed for any of these — the CSS cascade handles all token resolution.
css/
index.css Entry point — imports all layers in order
01-primitives.css Raw values
02-theme.css Light/dark palette
03-sub-styles.css Color family selectors
04-style.css Unified ---style-* scale
05-priority-toggle.css State → style step mappings
06-status.css Pseudo-class activation rules + ring spreads
07-semantic.css Component-ready token names
spacing.css
typography.css
size.css
device-grid.css
flags.css
components/
interactiveAttrs.ts Shared data-* attribute assembler for interactive components
index.ts Barrel re-export for all components and types
Button.tsx Reference implementation — priority pipeline
Icon.tsx Reference implementation — size tokens
Card.tsx Reference implementation — semantic surface
*.tsx Typed stubs (see TODO in each file)
tokens.ts TypeScript constants for all CSS variable names + dataAttr helpers
storybook/
components/ Per-component story files
pages/
templates/
.storybook/
main.ts Story glob patterns and addon config
preview.ts Global decorators (theme, token CSS import)