A small, real reference implementation of a two-tier design token
architecture — primitives → semantics — with a multi-platform build
(CSS custom properties, JSON, Kotlin/Jetpack Compose, Swift), semantic
versioning + changelogs per package, a public Storybook, and React components
including a production-grade Button that demonstrates the ShadCN-style API
surface (polymorphic as, asChild via Radix Slot, variant-to-token mapping,
and forwarded refs), an IconButton with a required accessible name, and a
Material Symbols wrapper with shared fixed-size presets.
The token/semantic split and CSS bridging pattern here follow a common design-system convention — a primitive palette layer feeding an intention-based semantic layer — built from scratch as a small, standalone, public case study.
| Package | What it is |
|---|---|
@svx/tokens |
Primitive + semantic tokens authored as plain CSS custom properties, plus a build that lowers/parses them into dist/css, dist/json, dist/compose (Kotlin), and dist/swift. |
@svx/react |
Button, IconButton, and MaterialSymbol components + Storybook. Consumes only semantic tokens, never primitives, directly. |
Tokens are authored as CSS, not JSON — a primitive layer (primitives.css)
feeding an intention-based semantic layer (semantic.css):
Primitive color families use ordinal stops in increments of 100, ordered
light to dark (for example, --color-neutral-100). These numbers do not imply
equal perceptual intervals or equivalent lightness across different families.
Size primitives use pixel-equivalent suffixes: --size-px-2 is 2px,
--size-px-8 is 8px, and --size-rem-14 is 0.875rem (14px at a 16px
root). The rem values scale with the user's root font size. Existing px/rem
variants share the same naming scale; only needed stops are declared.
packages/tokens/src/primitives.css @theme static { } existing size/type values plus
palette values needed by the system semantic
surface/text/icon/border/interactive/status roles
packages/tokens/src/semantic.css existing control tokens plus the system semantic
API; `.dark { }` and `.high-contrast { }` contain
semantic overrides only (never primitive overrides)
│
▼ build-tokens.mjs (parses the CSS with postcss — no separate JSON source)
packages/tokens/dist/
css/index.css `@theme static` lowered to plain `:root { }` + mode overrides,
so it works with or without Tailwind
json/tokens.json { light, dark, highContrast }, resolved, nested by category
json/tokens.flat.json same, flat
compose/SvxTokens.kt SvxTokensLight / SvxTokensDark / SvxTokensHighContrast
swift/SvxTokens.swift SvxTokensLight / SvxTokensDark / SvxTokensHighContrast
│
▼
packages/react/src/styles.css maps each Button variant to semantic CSS variables only
packages/react/src/button/ polymorphic `as`, `asChild` via @radix-ui/react-slot,
variant/size via class-variance-authority, forwardRef
packages/react/src/icon-button/ builds on Button with a required accessible name
packages/react/src/icons/ provides the Material Symbols Rounded wrapper and size presets
MaterialSymbol icons are decorative by default and accept a ligature name
(for example, add). The optional @svx/react/material-symbols.css stylesheet
loads the Material Symbols Rounded variable font from Google Fonts; import it
alongside @svx/react/styles.css to render those ligatures. Applications that
need self-hosted fonts can provide the same font family locally instead. The
font is distributed under the Apache License 2.0.
Button and IconButton support only Material Symbols for their icon props.
Pass ligature names rather than React elements; each control renders
MaterialSymbol internally. SplitButton uses IconButton for its dropdown
trigger. All three share icon sizes of 12px / 16px / 20px for sm / md /
lg controls, and the internal SVG loading spinner follows the same sizing.
<Button iconStartIcon="add" iconEndIcon="arrow_forward">Add item</Button>
<IconButton label="Add item" icon="add" />This replaces the previous React-node icon props: change
icon={<MaterialSymbol name="add" />} to icon="add" (and likewise for
iconStartIcon / iconEndIcon). Load the font stylesheet or supply a
self-hosted font for all three controls, including SplitButton.
--system-interactive-surface-hover/-pressed (used by the outline/ghost variants)
are a color-mix() overlay, not a hand-picked swatch, so they composite
correctly over any surface underneath. --system-interactive-solid* (the filled
variant) still steps through fixed ramp swatches, since a solid fill needs to
be fully opaque; see the comments in semantic.css for why each approach is
used where it is. A few pre-existing non-color component sizing tokens remain
temporarily; the token file identifies them and their consumers for follow-up.
Any token whose resolved value isn't a plain hex color / px dimension / plain
number (e.g. a future gradient or color-mix()) is intentionally skipped from
the Compose/Swift output — flagged in a comment — since CSS-only values like
gradients have no native platform representation.
See docs/wcag3-alignment.md for how contrast, target
size, and focus visibility are enforced today and what's tracked for when
WCAG 3.0's APCA-based conformance model stabilizes.
All six primitive color families use 12 light-to-dark stops (100–1200).
Blue 800 is Apple system blue #007AFF; solid actions with small white text
use a darker semantic mapping to meet 4.5:1 contrast. Error, success, warning,
and info palettes use coordinated red, green, amber, and informational blue.
Magenta is removed; escalated workflow status now uses the error foreground.
Semantic names stay stable, but palette colors and removed primitive stops are
intentional theme changes. Update direct primitive consumers; application
components should consume semantic roles instead.
The px/rem size ramps have identical steps and unit-first names:
--size-px-2 and --size-rem-2. Steps describe pixel equivalents at a
16px root. Previous unit-suffix names are removed; migrate direct references
and overrides. Typography and motion have their own meaningful scales; they are
not padded to match the number of color stops.
App-wide spacer semantics use --system-spacer-rem-{size} and
--system-spacer-px-{size} for margins, padding, and layout gaps:
| Size | Baseline px | rem |
|---|---|---|
2xs |
2 | 0.125 |
xs |
4 | 0.25 |
sm |
8 | 0.5 |
md |
12 | 0.75 |
lg |
16 | 1 |
xl |
24 | 1.5 |
2xl |
32 | 2 |
3xl |
40 | 2.5 |
Prefer rem for text-led forms, menus, content padding, and application layout
that should follow the computed root (html) font size. It does not follow a
component's local font-size override. For example,
gap: var(--system-spacer-rem-md) is 12 CSS px at a 16px root and 15 CSS px at a
20px root; --system-spacer-px-md remains 12 CSS px in both cases.
Use px for deliberately fixed CSS-pixel spacing, such as the current
pixel-sized Button's padding and icon-label gaps, not as the default for
text-led content. CSS pixels are not physical device pixels, and normal browser
page zoom enlarges both px and rem. Neither unit responds to viewport width
automatically or prevents text overflow in fixed-height controls; use responsive
layout rules and allow content to grow or wrap. Root-relative spacing follows
browser font preferences only when the root font-size configuration respects
those preferences. These roles are consistent across theme modes.
Storybook's Foundations / Color and Foundations / Size each have
Primitives and Semantics stories. Color Semantics lists app-wide color roles;
Size Semantics lists the spacer scale with rendered previews.
Foundations / Typography has Primitives and Semantics stories, while
Foundations / Motion retains the timing primitive reference.
Color Semantics shows the CSS variable, square light-mode and dark-mode color
previews, usage guidance, the authored light-mode mapping, and the resolved
light-mode value. Both previews remain fixed when the mode selector changes.
Color roles share one table with purpose filters, an All option, and token counts.
Each mode preview is a single colored square; transparent colors composite over
the table surface.
System usage descriptions identify the CSS role, example elements, intended
surface or state, and companion tokens where relevant. They describe purpose
rather than assuming a particular palette value or guaranteeing contrast.
These descriptions live in inline comments beside the base declarations in
packages/tokens/src/semantic.css; Storybook reads them directly so source
documentation and the reference table stay aligned across modes.
All foundation references share a page header, and color primitives use connected
palette strips with an Apple blue anchor marker.
Size and component references show selected-mode values and transitive primitive
dependencies. Data comes from the CSS sources, not a
duplicated palette snapshot. Color-mix values remain CSS expressions; their
displayed previews are rendered by the browser. Usage describes intent rather
than guaranteeing contrast on every possible surface.
Reference layouts share the Storybook-only .storybook/storybook.css stylesheet,
imported once by the preview. Its sbx-* classes provide themed tables, sticky
headers, keyboard-focusable scroll regions, palette grids, and transparency
checkerboards. Only token-driven preview values remain inline. These styles are
not included in the published React component stylesheet.
The semantic layer and Semantics story contain only --system-* roles.
Component-owned tokens live in packages/tokens/src/components/, grouped by
owner and imported through components.css. This includes Switch geometry and
thumb treatment, Field gaps, Label/SupportingText typography, Input/Textarea
sizes, Tooltip surfaces, and workflow status mappings. These roles use
--component-* names rather than --system-*; update overrides using their
previous names. Shared compatibility aliases remain in shared-controls.css.
The canonical bundle loads primitives, app-wide semantics, then component
tokens. Color mappings consume system roles; geometry consumes primitive scales.
Component stories include a Tokens table; Toggle shares Button tokens and
Textarea shares Input tokens.
Component padding and gaps map to spacer semantics through their component
tokens: Button (also Toggle/SplitButton), Label, SupportingText, Input/Textarea,
and Menu. Button spacing stays pixel-exact; form-text and Menu spacing retain
rem scaling. Compatibility spacing aliases also map to the system scale.
Two intentional 6px exceptions remain: Field's vertical gap and large
Input/Textarea vertical padding. Their component tokens reference --size-rem-6
because the T-shirt spacer scale has no 6px step. Switch thumb inset, focus-ring
offset/width, radii, and control/icon dimensions are geometry, not layout
spacers, and remain independent. Zero spacing remains a literal reset.
Typography follows primitives into prescribed system bundles. Primitive sizes
run from xs (12px equivalent) through 5xl (40px equivalent), with sans/monospace
families, 400/500/600/700 weights, unitless line-height ratios, and tracking.
Font sizes use rem. Existing dimension-based --line-height-* primitives
remain available; system bundles use --line-height-ratio-* so their line
boxes follow the element's font size.
The default sans family is self-hosted Inter Variable, provided by the
@fontsource-variable/inter dependency and imported by the component stylesheet.
Its normal style supports weights 100-900 and loads with font-display: swap;
the system-font fallback remains available during loading. Font files are
served by the application's CSS bundler, not requested from Google Fonts.
The package includes the SIL Open Font License, also copied to
@svx/react/dist/inter-OFL.txt by the build. Retain the font license when
redistributing font assets. Use the same family token to rebrand centrally.
Each --system-type-{role} bundle has -font, -tracking, and -color members:
.page-title {
font: var(--system-type-h1-font);
letter-spacing: var(--system-type-h1-tracking);
color: var(--system-type-h1-color);
}Apply the complete bundle rather than independently choosing size, weight,
tracking, or foreground. font is a CSS shorthand; it does not include
letter spacing or color. Type styles do not supply margins or layout gaps.
| Role | Intended markup | Baseline size / line height | Weight | Foreground |
|---|---|---|---|---|
| h1 | h1, main page title | 32 / 40 | 700 | Primary |
| h2 | h2, major section | 24 / 32 | 600 | Primary |
| h3 | h3, subsection | 20 / 28 | 600 | Primary |
| h4 | h4, nested subsection | 18 / 28 | 600 | Primary |
| h5 | h5, deeper heading | 16 / 24 | 600 | Primary |
| h6 | h6, deepest heading | 14 / 20 | 600 | Primary |
| body | p, ordinary content | 16 / 24 | 400 | Primary |
| body-secondary | p, supporting content | 16 / 24 | 400 | Secondary |
| body-sm | p, compact content | 14 / 20 | 400 | Primary |
| label-sm | span, compact interface text | 12 / 16 | 500 | Primary |
| label-md | span, standard interface text | 14 / 20 | 500 | Primary |
| label-lg | span, larger interface text | 16 / 24 | 500 | Primary |
| caption | span, metadata and annotations | 12 / 16 | 400 | Secondary |
| code | code, inline technical content | 14 / 20 | 400 | Primary |
Heading levels follow document hierarchy, not the desired visual size. The label bundles do not replace the accessible Label component. Label sizes sm/md/lg map to the corresponding system label fonts and tracking. SupportingText sm/md use caption, while lg uses body-sm. Their existing sizes, line heights, native markup, required/optional indicators, icons, and accessible associations are preserved. Both components retain secondary, error, success, or disabled foreground handling rather than inheriting the generic type-bundle color. Other controls retain their existing typography mappings. These bundles target standard content surfaces; no speculative accent, inverse, or on-deep variants are introduced. Foreground aliases follow the theme's existing text roles in root-level and scoped dark/high-contrast modes.
Secondary text uses neutral 900 (#595964) in light mode and neutral 600
(#afafb8) in dark mode for clearer separation from primary text. It retains
at least 4.5:1 contrast on the standard content surfaces tested in each mode.
High-contrast mode intentionally retains the primary-strength foreground.
Typography Semantics displays all 14 bundles as real text specimens, with intended markup, usage, authored mappings, resolved values, and primitive dependencies. These are token foundations, not a new public React typography component or global heading reset.
Skeleton reserves space while content is loading. It supports text (default),
circle, and rectangle variants, with sm/md/lg sizes:
<section aria-label="Account details" aria-busy={loading}>
{loading ? <Skeleton lines={3} /> : <AccountDetails />}
</section>Text sizes reserve 16/20/24px-equivalent line boxes; multiple lines have a
deterministic 80% final line. Circle sizes are 24/32/40px equivalents. Rectangles
fill the available width with the same default heights; a parent-specific
style or class may reserve the final image/card dimensions. Match the eventual
content layout to avoid layout shifts. lines must be a positive integer and
is supported only for text. Placeholders accept no content, are aria-hidden and
inert, and never announce their own status. The containing feature owns
aria-busy, an optional single loading announcement, errors, and replacement
with real content. Do not cover still-focusable controls with a placeholder.
The generic system roles are:
--system-loading-surface: resting placeholder fill.--system-loading-shimmer: translucent highlight over that fill.--system-animation-loading-shimmer: shared animation shorthand and keyframes, built from duration/easing primitives.
Skeleton consumes these through its component-owned tokens. Other decorative
placeholders can reuse the .svx-loading-shimmer class, or use the semantic
animation on a full-width, absolutely positioned overlay clipped by its parent:
.custom-placeholder {
position: relative;
overflow: hidden;
background: var(--system-loading-surface);
}
.custom-placeholder::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
90deg, transparent, var(--system-loading-shimmer), transparent
);
transform: translateX(-100%);
animation: var(--system-animation-loading-shimmer);
}The animation translates the decorative layer, not layout or actual content.
Reduced-motion preferences set the system animation to none; the shared class
also removes its overlay. Forced-colors mode renders static outlined shapes.
Dark/high-contrast themes adjust loading fills without changing geometry.
Color Semantics includes loading colors, Motion Semantics documents the reusable
animation, and Skeleton stories show shapes, themes, and a loading-to-content
card. JSON/native exports describe base theme values, not runtime media-query
preferences. Existing Button spinners and progress patterns are unchanged.
Label renders a native label and SupportingText renders a paragraph. Both
support size="sm" | "md" | "lg" (default md), invalid, and disabled
styling, using semantic typography and color tokens. Disabled styling takes
precedence over invalid styling. Labels use secondary text color and medium
weight; supporting text uses regular weight with a smaller scale:
| Size | Label | Uppercase label | Supporting text / icon |
|---|---|---|---|
sm |
12px / 16px | 12px / 16px | 12px / 16px / 12px |
md |
14px / 20px | 12px / 16px | 12px / 16px / 12px |
lg |
16px / 24px | 14px / 20px | 14px / 20px / 14px |
These are baseline dimensions at a 16px root; typography scales in rem.
Use textCase="uppercase" on Label for uppercase text and optional for a
smaller, regular-weight (optional) hint. Required takes precedence over
optional. Invalid labels retain their normal color; validation is communicated
by the control and error message.
<Label htmlFor="email" required>Email</Label>
<input id="email" type="email" required aria-describedby="email-hint" />
<SupportingText id="email-hint">Use your work email.</SupportingText>Label's required prop adds a red asterisk, hidden from
assistive technology to avoid duplicating the control's native required state.
Set required on the control as well. Likewise, set disabled and
aria-invalid on the control itself; text-component state props only affect
appearance. Connect supporting text using aria-describedby. Error messages
should explain the problem in words, not just change color.
SupportingText supports variant="default" | "error" | "success" | "disabled"
and optional startIcon / endIcon flags. These render decorative Material
Symbols appropriate to the variant, aligned to the first line when text wraps.
Load the Material Symbols stylesheet (or supply the font locally) when using
icons. invalid selects the error variant; disabled overrides all variants.
SupportingText does not automatically announce content as a live region.
For validation updates that need announcements, explicitly pass role="alert"
or an appropriate aria-live value. Avoid duplicate announcements when a
form-level error summary already provides feedback.
Field connects one control to its label, descriptions, and error messages.
It owns size, required, disabled, and invalid for its parts:
<Field required invalid={hasError}>
<FieldLabel>Email</FieldLabel>
<FieldControl>
<Input type="email" />
</FieldControl>
<FieldDescription>Use your work email.</FieldDescription>
<FieldError>Enter a valid email address.</FieldError>
</Field>FieldControl renders no extra wrapper. Pass exactly one native input,
textarea, or select, or a component that forwards native control props and
its ref. The field supplies the control ID and required/disabled/invalid
state; configure these on Field, not the child. Use controlId on Field
for an explicit control ID; id on Field identifies the wrapper.
Field also supplies data-size to the control, overriding Input/Textarea's
standalone visual size. A valid Field sets aria-invalid="false" explicitly
so it also overrides a child's standalone invalid prop.
Descriptions register after mounting and are removed from aria-describedby
when unmounted. Existing external description IDs on the child are preserved.
Generated IDs are stable across server rendering and hydration, but automatic
description associations become available after hydration. FieldError
renders only when invalid is true. Validation is owned by the application.
Field uses a 6px vertical gap. FieldError includes a decorative status icon
by default; pass startIcon={false} to omit it.
Field does not validate values or create announcements automatically. Use
role="alert" on FieldError when appropriate. Keep descriptions/errors
inside their Field and use unique IDs if supplying them explicitly.
Input and Textarea render native elements with forwarded refs, native
controlled/uncontrolled values and events, semantic theme colors, and the same
svx-focus-ring utility as Button. They do not generate labels or validation
messages: use Field as above, or supply an accessible name and descriptions
yourself.
Both support size="sm" | "md" | "lg" (default md), disabled, readOnly,
and invalid. Explicit aria-invalid takes precedence over invalid.
Disabled styling takes precedence over error styling; read-only text remains
selectable and focusable. The focus ring remains the shared focus color when
invalid, with an error-colored border.
| Size | Input minimum height | Text typography |
|---|---|---|
sm |
24px | 12px / 16px |
md |
32px | 14px / 20px |
lg |
40px | 16px / 24px |
These controls fill their container. Input's visual size replaces the native
numeric attribute; use htmlSize for that attribute instead. Textarea defaults
to rows={3}, supports native rows/cols/maxLength, and can be resized
vertically. Override style.resize if needed. No auto-grow or custom scrollbar
behavior is added. Native input types are forwarded without custom pickers or
file-upload UI; the visual sizing is intended primarily for text-like inputs.
System input height, padding, typography, and radius tokens define the shared
size scale, while existing surface, text, border, and status roles supply colors.
<Input aria-label="Search" type="search" placeholder="Search..." />
<Textarea aria-label="Notes" rows={5} maxLength={500} />These are different interaction contracts, not interchangeable appearances:
Toggleis a native button witharia-pressedfor modes/actions such as bold. Its accessible name should stay the same when pressed. It defaults totype="button", supports Enter and Space, and does not submit a checked form value. Usepressed/onPressedChangefor controlled state ordefaultPressedfor uncontrolled state. It reuses Button's sizes, shared focus ring, and Material SymbolsiconStartIcon/iconEndIconprops. Icon-only toggles need an accessible name such asaria-label. Preventing the nativeonClickdefault cancels the state change.Switchis a native checkbox withrole="switch"for on/off settings. Usechecked/onCheckedChange(or nativeonChange) for controlled state, ordefaultCheckedfor uncontrolled state. Native label activation, Space,name/valueform submission,required, and uncontrolled form reset work without a hidden form proxy. Only checked, enabled switches submit a value ("on"unlessvalueis provided). It has no indeterminate or read-only mode. Reset controlled state in your own formonResethandler when needed.
Both support disabled, size="sm" | "md" | "lg" (default md), and forwarded
refs. Toggle's sizes match Button's 24/32/40px heights. Switch tracks are
28x16 / 32x20 / 40x24px with 12/16/20px thumbs. Switch uses logical positioning
for RTL layouts, respects reduced motion, and supports system forced colors.
Its compact track is not an enlarged touch target; use a clickable label row
with appropriate spacing when a larger target is needed.
Toggle's persistent pressed appearance uses the selected interactive surface and
its surface foreground, independently of hover and pointer-down overlays.
Switch uses neutral interaction overlays on its off track and solid interactive
colors on its on track. The bordered thumb stays light in either state, using
shared --component-switch-thumb-surface / --component-switch-thumb-border roles
and their -disabled counterparts. These roles are defined per theme rather
than borrowing text colors or choosing palette stops in component styles.
Switch accepts invalid, with explicit aria-invalid taking precedence.
Compose it with Field for automatic labels, descriptions, size, and
required/disabled/invalid state. FieldControl and the forwarded ref target the
native input; Switch adds a small presentation wrapper for its decorative thumb.
className and native props/style apply to the input. Toggle is an action button,
not a Field control. Neither component performs application validation or saves
a setting for you.
<Toggle defaultPressed iconStartIcon="format_bold">Bold</Toggle>
<Field>
<FieldLabel>Notifications</FieldLabel>
<FieldControl>
<Switch name="notifications" checked={notifications} onCheckedChange={setNotifications} />
</FieldControl>
<FieldDescription>Receive email notifications.</FieldDescription>
</Field>corepack enable
pnpm install
pnpm build # builds tokens, then react (turbo respects the dependency)
pnpm test
pnpm storybook # http://localhost:6006This repo uses Changesets for
independent semantic versioning and per-package CHANGELOG.md generation.
pnpm changeset # describe your change, pick bumped packages
pnpm version-packages # applies version bumps + changelog entries
pnpm release # build + publish.github/workflows/ci.yml— install, build, test, typecheck on every push/PR..github/workflows/release.yml— Changesets release PR / publish flow onmain..github/workflows/storybook.yml— builds@svx/react's Storybook and deploys it to GitHub Pages onmain.
MIT — see LICENSE.