Design System
Primitives · Semantics · Typography · Components
variables.json using Variables2JSON pluginnpm run build, commits updated build/ back to the PR. Fails if any token is broken.Lives in .github/workflows/build-tokens.yml. Triggers only when variables.json changes — not on every push.
on:
pull_request:
paths:
- variables.json # only runs when the Figma export changes
jobs:
build-tokens:
runs-on: ubuntu-latest
steps:
- checkout the PR branch
- npm ci
- npm run build
# 1. transform.js → converts variables.json to W3C tokens/
# 2. validate.js → checks all 500+ references, fails if broken
# 3. sd.config.js → generates CSS, JS, TS types, Swift, Android
- git commit tokens/ build/ back to the PR branch
When the action runs, it pushes the rebuilt files back to the PR. The diff shows exactly what changed — across every platform — before anything ships.
# build/web/tokens.css - --semantic-surface-bg-brand: #4A5C2A; + --semantic-surface-bg-brand: #3B4A22; # build/web/tokens.d.ts ← TypeScript types updated too - export declare const semanticSurfaceBgBrand: string; (unchanged, but value behind it changed) # build/ios/TokensLight.swift - public static let semanticSurfaceBgBrand = UIColor(red: 0.290, green: 0.361, blue: 0.165, alpha: 1.000) + public static let semanticSurfaceBgBrand = UIColor(red: 0.231, green: 0.290, blue: 0.133, alpha: 1.000) # build/android/values/colors.xml - <color name="semantic_surface_bg_brand">#4A5C2A</color> + <color name="semantic_surface_bg_brand">#3B4A22</color>
Nothing changes in how developers write code. They already reference tokens by name. When the value behind a token changes, their UI updates automatically.
/* Developer wrote this once — never needs to change it */
.hero {
background: var(--semantic-surface-bg-brand);
}
/* After the PR merges and they pull:
git pull → the new tokens.css is there → UI reflects the new color */
How the pipeline works
One command generates all platform outputs from your Figma export.
npm run build # 1. transform.js — converts figma-tokens.json → tokens/*.json (Style Dictionary format) # 2. sd.config.js — runs Style Dictionary → build/ (platform-specific files)
Generated files:
build/web/tokens.css build/web/tokens.js build/ios/TokensLight.swift build/ios/TokensDark.swift build/android/values/colors.xml build/android/values-night/colors.xml build/rn/tokens.js/* ─── 1. Import once in your app entry point ─── */
<link rel="stylesheet" href="build/web/tokens.css">
/* tokens.css contains BOTH light (default) and dark mode:
:root { --color-lime-500: #ACFF0F; ... }
[data-theme="dark"] { --semantic-surface-bg-default: #000000; ... }
*/
/* ─── 2. Use semantic tokens in your components ─── */
.button-primary {
background-color : var(--component-button-primary-bg-default);
color : var(--component-button-primary-text-default);
height : var(--component-button-md-height);
border-radius : var(--component-button-border-radius);
padding : 0 var(--component-button-md-padding-x);
}
.button-primary:hover {
background-color: var(--component-button-primary-bg-hover);
}
/* ─── 3. Toggle dark mode by setting data-theme on <html> ─── */
document.documentElement.setAttribute('data-theme', 'dark');
document.documentElement.removeAttribute('data-theme'); /* back to light */
// ─── Named ES module exports (resolved values) ───────────────
import {
componentButtonPrimaryBgDefault,
componentButtonPrimaryTextDefault,
componentButtonMdHeight,
componentButtonBorderRadius,
semanticContentTextHeading,
colorLime500,
} from './build/web/tokens.js';
// Use in React / vanilla JS
const buttonStyles = {
backgroundColor : componentButtonPrimaryBgDefault, // '#ACFF0F'
color : componentButtonPrimaryTextDefault, // '#000000'
height : componentButtonMdHeight, // '36px'
borderRadius : componentButtonBorderRadius, // '8px'
};
// TypeScript: values are typed as string
// For stricter types, add a d.ts file or use the CSS approach.
traitCollection.userInterfaceStyle,
or create a wrapper that uses dynamic colors.
// ─── TokensLight.swift (auto-generated) ───────────────────────
import UIKit
public struct TokensLight {
public static let colorLime500 = UIColor(red: 0.675, green: 1.000, blue: 0.059, alpha: 1.000)
public static let semanticPrimaryBgSolid = UIColor(red: 0.675, green: 1.000, blue: 0.059, alpha: 1.000)
public static let componentButtonPrimaryBgDefault = UIColor(red: 0.675, green: 1.000, blue: 0.059, alpha: 1.000)
public static let componentButtonBorderRadius: CGFloat = 8
// ... hundreds more
}
// ─── Usage in a UIKit view ─────────────────────────────────────
class PrimaryButton: UIButton {
override func traitCollectionDidChange(_ previous: UITraitCollection?) {
super.traitCollectionDidChange(previous)
applyTokens()
}
func applyTokens() {
let t = traitCollection.userInterfaceStyle == .dark ? TokensDark.self : TokensLight.self
backgroundColor = t.componentButtonPrimaryBgDefault
layer.cornerRadius = t.componentButtonBorderRadius
}
}
// ─── Or use UIColor dynamic provider (iOS 13+) ─────────────────
let brandColor = UIColor { traits in
traits.userInterfaceStyle == .dark
? TokensDark.componentButtonPrimaryBgDefault
: TokensLight.componentButtonPrimaryBgDefault
}
res/values/
and build/android/values-night/colors.xml in res/values-night/.
Android automatically switches between them based on system dark mode — no code needed.
<!-- build/android/values/colors.xml (light — auto-generated) ──────────────────────────── --> <?xml version="1.0" encoding="UTF-8"?> <resources> <color name="color_lime_500">#ACFF0F</color> <color name="semantic_primary_bg_solid">#ACFF0F</color> <color name="component_button_primary_bg_default">#ACFF0F</color> <!-- ... --> </resources> <!-- build/android/values/dimens.xml (auto-generated) ───────────────────────────────── --> <resources> <dimen name="component_button_border_radius">8dp</dimen> <dimen name="component_button_md_height">36dp</dimen> <!-- ... --> </resources> <!-- Usage in a button style (styles.xml) ────────────────────────────────────────────── --> <style name="Widget.App.Button.Primary"> <item name="android:background">@color/component_button_primary_bg_default</item> <item name="android:minHeight">@dimen/component_button_md_height</item> <item name="android:paddingStart">@dimen/component_button_md_padding_x</item> </style> <!-- Android picks values-night/colors.xml automatically in dark mode ✓ -->
useColorScheme() to handle light/dark
— build two rn/tokens files (light + dark) for full mode support.
// build/rn/tokens.js (auto-generated, light mode values)
const tokens = {
color: { lime: { "500": "#ACFF0F" }, ... },
semantic: {
primary: { bg: { solid: "#ACFF0F" } },
content: { text: { heading: "#000000" } },
},
component: {
button: {
primary: { bg: { default: "#ACFF0F" }, text: { default: "#000000" } },
"border-radius": 8,
md: { height: 36, "padding-x": 16 },
},
},
};
// ─── Usage in a React Native component ────────────────────────
import { StyleSheet, useColorScheme } from 'react-native';
import lightTokens from './build/rn/tokens';
function PrimaryButton({ label }) {
const scheme = useColorScheme();
const t = scheme === 'dark' ? darkTokens : lightTokens; // swap per mode
const styles = StyleSheet.create({
button: {
backgroundColor : t.component.button.primary.bg.default,
height : t.component.button.md.height,
paddingHorizontal: t.component.button.md['padding-x'],
borderRadius : t.component.button['border-radius'],
},
label: {
color: t.component.button.primary.text.default,
},
});
return <TouchableOpacity style={styles.button}><Text style={styles.label}>{label}</Text></TouchableOpacity>;
}
Three-tier system. Each tier has one job. Never skip a tier — always resolve through primitives → semantics → component tokens.
Every semantic token follows a consistent four-segment path. Learning this pattern means you can predict any token name without looking it up.
| Segment | What it is | Values |
|---|---|---|
group | The domain or role category | surface · content · primary · neutral · status · disabled · selected · focus |
role | Status variant (status/* only) | error · success · warning · info |
property | What is being styled | bg · text · icon · border |
state | Visual variant or interaction state | solid · subtle · solid-hover · subtle-hover |
All semantic token pairings have been contrast-tested. Use this as the contract for what is and isn't acceptable in the Hopper system.
content/text/heading+bodyon allsurface/bg/*— 13:1 to 21:1primary/bg/solid+primary/text/solid— 17:1 both modesneutral/bg/solid+neutral/text/solid— 9.9:1 both modes- All
status/*/text/solidonstatus/*/bg/solid(except warning) — 8:1+ selected/textonselected/bg— 13.4:1focus/ringon canvas — 13.6:1 light · 16.6:1 darkcontent/text/linkonsurface/bg/default— 13.6:1 both modes
status/warning/*solid or subtle pairings — 5.66:1 (yellow physical limit)status/error/text/solidonstatus/error/bg/solidin dark mode — 5.2:1content/text/captiononsurface/bg/*— 4.5:1 to 5.8:1content/text/placeholderonsurface/bg/default— 5.06:1 light
content/text/placeholderonsurface/bg/raisedor/sunken— drops below 3:1- Any
primary/bg/subtleas a standalone selection signal on white — 1.25:1 (invisible) - Any primitive color directly on any element — unvalidated
- Mixing token suffixes: solid text on subtle bg or vice versa
surface/bg/default instead.
These rules protect the system's integrity, WCAG compliance, and theming correctness.
primary/bg/subtle is 1.25:1 on white — invisible alone. Always pair with selected/border as the real signal.border-width/md. Always include focus/ring-offset to create the gap between element and ring.surface/border/default → foundation/white
gray/200.content/text/link-hover = same as link in light
font-weight/Semi Bold has a space
SemiBold if tooling is case-sensitive.