Tokens
Named design decisions that travel from a designer's file to a compiled binary without anyone retyping a hex code — and, in a regulated product, without anyone being able to change a safety-critical value by accident.
Overview
A token is a name bound to a value. The name expresses intent — --muted-foreground,
--alarm-critical — and the value can change per theme without any component knowing.
That indirection is what makes a dark theme a configuration change rather than a redesign.
In a SaMD the indirection buys something else as well: a single auditable place where safety-relevant visual decisions live. When a notified body asks how you guarantee that a high-priority alarm is red in every screen of every product, the answer is one token definition and the lint rule that protects it — not a review of four hundred components.
Intentional
A token name says what the value is for, never what it looks like. There is no
--teal-button; there is --primary.
Layered
Primitives hold raw values, semantics bind them to roles, components consume only semantics. A component that reaches past its layer is a bug.
Governed
Most tokens are free to be re-themed. A small, explicitly marked set is not, because changing it changes what the interface means.
The three layers
A value travels through exactly three stops. Skipping one is what makes design systems drift.
--njt-teal-800: #0F3B35
Primitive — a raw ramp step.
Has no meaning yet. Never used by a component.
--primary: var(--njt-teal-800)
Semantic — binds the value
to a role. This is the layer that changes between themes.
className="bg-primary"
Component — consumes the
role. Knows nothing about teal.
| Layer | Prefix | Example | May a product change it? |
|---|---|---|---|
| Primitive | --njt-* |
--njt-teal-800 | No — the ramp is the brand. |
| Semantic | shadcn names | --primary, --muted | Yes — this is the theming surface. |
| Semantic, governed | --alarm-* |
--alarm-critical | No — controlled characteristic. |
| Documentation | --doc-* |
--doc-coral-text | N/A — never ships in a product. |
The shadcn contract
The semantic layer uses shadcn/ui's variable names verbatim. This is deliberate and it is the
main reason to build on shadcn at all: any block, example or third-party component from the
wider ecosystem drops into this system and is instantly on-brand, because it is already reaching
for --primary and --muted.
/* app/globals.css */
:root {
--background: #FDFCF8; --foreground: #16231F;
--card: #FFFFFF; --card-foreground: #16231F;
--popover: #FFFFFF; --popover-foreground: #16231F;
--primary: #0F3B35; --primary-foreground: #F6F1E7;
--secondary: #EFEDE4; --secondary-foreground: #16504A;
--muted: #EFEDE4; --muted-foreground: #54635E;
--accent: #E4EEE9; --accent-foreground: #0F3B35;
--destructive: #CE0E2D; --destructive-foreground: #FFFFFF;
--border: #DEE4DF; --input: #DEE4DF;
--ring: #2A8B7C; --radius: 0.625rem;
}
.dark {
--background: #0B2A26; --foreground: #F6F1E7;
--card: #0F3B35; --primary: #3BC4AF;
--muted: #16504A; --muted-foreground: #A8BDB6;
--border: #1E4A43; --ring: #3BC4AF;
--destructive: #D93650;
}
The alarm extension
Stock shadcn has no concept of alarm priority. It has --destructive, which means
"this control destroys data" — a completely different job from "this patient may be having a
STEMI." Conflating them is the single most likely way for a team new to medical software to build
something unsafe.
/* Medical extension — IEC 60601-1-8 alarm priorities.
GOVERNED: not re-themable by product, customer skin, or user preference.
Identical in light and dark. Changing a value here requires re-validation. */
:root, .dark {
--alarm-critical: #CE0E2D; --alarm-critical-foreground: #FFFFFF;
--alarm-urgent: #F2C200; --alarm-urgent-foreground: #000000;
--alarm-advisory: #4FB3D9; --alarm-advisory-foreground: #000000;
--alarm-info: #B3BEB9; --alarm-info-foreground: #000000;
--alarm-ok: #1D6E62; --alarm-ok-foreground: #FFFFFF;
}
Wiring into Tailwind
// tailwind.config.ts
theme: {
extend: {
colors: {
// …stock shadcn keys…
alarm: {
critical: "var(--alarm-critical)",
"critical-foreground": "var(--alarm-critical-foreground)",
urgent: "var(--alarm-urgent)",
"urgent-foreground": "var(--alarm-urgent-foreground)",
advisory: "var(--alarm-advisory)",
"advisory-foreground": "var(--alarm-advisory-foreground)",
info: "var(--alarm-info)",
"info-foreground": "var(--alarm-info-foreground)",
ok: "var(--alarm-ok)",
"ok-foreground": "var(--alarm-ok-foreground)",
},
},
},
}
Governance
Tokens split into two governance classes. The distinction is the whole point of this page.
| Class | Tokens | Change process |
|---|---|---|
| Open | All shadcn semantics — --primary, --muted,
--radius, spacing, motion |
Normal design review. Theme overrides permitted. |
| Governed | --alarm-*, focus-ring visibility, minimum touch target,
minimum body size |
Design change with safety consequence. Requires risk assessment, usability re-validation and a documented rationale. |
Enforcement
A rule that lives only in documentation is not a control. The governed set is enforced by a dependency-free gate that runs on every pull request and fails the build on violation.
| Check | Asserts | Catches |
|---|---|---|
| parity | Governed alarm tokens resolve identically in light and dark | A theme quietly changing what a signal means |
| contrast | Every declared token pair meets its documented minimum | A "small tweak" pushing text below AA |
| sync | The shipped stylesheet matches tokens/tokens.json exactly |
Drift between the source of truth and what actually renders |
| redefinition | No governed token is re-declared outside its blessed block, and no governed value appears as a literal in product source | A customer skin overriding an alarm hue; a hard-coded hex that survives a validated palette change |
| separation | Brand hues stay perceptually distant (ΔE) from alarm hues | A brand colour drifting close enough to alarm red to be confusable |
Running it
$ npm run check:tokens
Token governance gate — NotJustAnyMed.Tech Design System
──────────────────────────────────────────────────────────────
PASS parity 10 governed alarm tokens identical across both themes
PASS contrast 34 colour pairs meet their documented minimum
PASS sync assets/njt-ds.css matches tokens.json
PASS redefinition 8 files scanned, no governed token redefined or hard-coded
──────────────────────────────────────────────────────────────
note ΔE(alarm-critical #CE0E2D, #D8593A) = 22.6
note ↳ below the 25 separation threshold — this is why coral is
banned from product UI.
──────────────────────────────────────────────────────────────
All governance contracts hold.
What a violation looks like
FAIL parity --alarm-critical differs between themes
light #CE0E2D vs dark #E24359. IEC 60601-1-8 hues are
absolute; a theme may not change what a signal means.
FAIL redefinition --alarm-urgent is declared outside the blessed blocks
assets/njt-ds.css:405 → #FF9900. Governed tokens have
exactly one definition point; a skin may not override them.
FAIL redefinition Literal #CE0E2D appears in src/Widget.tsx
That is the value of --alarm-critical. Use the token:
a literal is invisible to this gate.
$ echo $?
1
On first run against this system it failed. --input had inherited shadcn's
default of matching --border, giving a control boundary of 1.10:1 against the worst surface it sits on
— below the 3:1 that WCAG 2.2 SC 1.4.11 requires for identifying a component. The token is now
#76827C at 3.41:1 against the worst surface it can sit on, and is governed, because a clinician who cannot
locate the edge of a field is a use-error risk, not a visual preference.
This is what a control is for. A design system that only documents its rules discovers this at audit; one that enforces them discovers it on the first commit.
Files
| Path | Role |
|---|---|
tokens/tokens.json |
Single source of truth. Every token, both themes, governance class, and a
rationale string on each governed entry. |
scripts/colour.mjs |
WCAG relative luminance and contrast, CIE76 ΔE. No dependencies. |
scripts/check-tokens.mjs |
The token gate. Exits non-zero on violation. --json for machine
output. |
scripts/check-docs.mjs |
The documentation gate — the same discipline applied to the pages that explain the
tokens. Ten checks: cross-references, class and var() resolution, required
sections, table scopes, unqualified validation claims, page structure, sitemap
reachability, build-status counts and the reference-application register. |
.github/workflows/design-system.yml |
Runs both on every PR as independent jobs, and retains the JSON reports for 90 days as design-control evidence. |
Every governed token carries a written rationale in tokens.json. When a
reviewer asks why high-priority alarms are #CE0E2D, the answer is in the same file
as the value, in version control, with the commit that introduced it — rather than in a
designer's memory.
Non-colour scales
Spacing — 4 px base
Every gap is a multiple of four. Tailwind's default scale already is, so no override is needed; the constraint is on usage, not configuration.
| Token | Value | Typical use |
|---|---|---|
--njt-s-1 | 4px | Icon to label |
--njt-s-2 | 8px | Within a control |
--njt-s-3 | 12px | Between related controls |
--njt-s-4 | 16px | Card padding, default gap |
--njt-s-6 | 24px | Between groups |
--njt-s-8 | 32px | Between sections |
--njt-s-12 | 48px | Between major regions |
Radius
One base token, two derived. Matches the brand's 6 / 10 / 16 geometry.
| Token | Value | Use |
|---|---|---|
--radius-sm | 6px | Buttons, inputs, chips |
--radius-md | 8px | Alerts, popovers |
--radius / -lg | 10px | Cards, dialogs, panels |
Motion
| Token | Value | Use |
|---|---|---|
--njt-dur-1 | 80ms | Hover, press feedback |
--njt-dur-2 | 140ms | Colour and state change |
--njt-dur-3 | 220ms | Entry and exit |
--njt-dur-4 | 320ms | Larger surfaces, panels |
--njt-ease-standard | cubic-bezier(.2,0,0,1) | Begins and ends at rest |
--njt-ease-enter | cubic-bezier(0,0,0,1) | Incoming elements |
--njt-ease-exit | cubic-bezier(.3,0,1,1) | Exiting elements |
Do's and don'ts
<div className="bg-alarm-critical
text-alarm-critical-foreground">
Consume the semantic role. The component stays correct when the theme changes and cannot drift from the standard.
<div className="bg-[#CE0E2D]
text-white">
A hard-coded hex is invisible to the contrast test and the lint rule. It will survive a palette change that everything else follows.
--primary: #0F3B35; /* open */ --alarm-urgent: #F2C200; /* governed */
Theme the open set freely. Leave the governed set alone.
.customer-acme {
--alarm-urgent: #FF9900; /* on-brand! */
}
A customer skin overriding an alarm hue. Now "urgent" means something different on this deployment than every other device in the room.
More do's and don'ts
"alarm-urgent": {
"class": "governed",
"light": "#F2C200",
"dark": "#F2C200",
"rationale": "IEC 60601-1-8 medium priority."
}
A governed value carries its rationale in the same file, in version control, with the commit that set it.
/* hotfix: customer said the yellow looked harsh */ --alarm-urgent: #FFE066;
A safety-relevant value changed in a hotfix, with the reason in a comment that will not survive the next refactor.
.alert { background: var(--alarm-urgent); }
Components consume the semantic layer, so they stay correct when a theme changes and cannot drift from the standard.
.alert { background: var(--njt-warm-400); }
A component reaching past its layer into a primitive. It now has a colour with no role and no governance.
Clinical safety notes
Trace these in your risk file (ISO 14971).
- Single definition point for alarm appearance. Mitigates: divergent alarm presentation across products, and the audit burden of proving consistency component by component.
- Governed set is machine-enforced. A lint rule and a CI contrast test, not a paragraph of guidance. Mitigates: well-intentioned local overrides.
- Themes cannot alter meaning. Dark mode changes surfaces, never signals. Mitigates: a clinician misreading priority after a theme switch.
- No hard-coded values in components. Mitigates: silent drift that survives a validated palette change.
Related
- Colour — the ramps and the alarm hues in full.
- Typography — the type tokens and the scale they build.
- Alert Banner — the alarm tokens applied.