- Design system
- Foundations
02 — Foundations
Foundations
Colour, type, space, radius, shadow and motion. Everything downstream resolves to a token defined here, so this is the only file you edit to re-skin a whole property.
Surfaces
Four depths, no more. --bg is the page. --bg-sunken recedes — use it to band alternating sections and for wells. --bg-elevated lifts — cards, menus, modals. --bg-glass is the sticky header only.
light-dark() token works for both.
Ink
Three weights of text. There is no fourth — if something needs to be quieter than --text-3, it probably should not be on the page.
Accent
Deep green on light, mint on dark. Both clear WCAG AA against their own page ground. The accent marks one thing per view — a primary action, or the current item, not both.
Status
Four intents for validation and alerts. Each has a matching -wash. Colour never carries meaning alone — always pair it with a word or an icon.
Lines
Both hairlines are alpha values, not solid greys — they have to sit correctly on any of the four surfaces. --hairline separates; --hairline-soft outlines a card without drawing attention.
--hairline — table rules, section dividers, input borders--hairline-soft — card outlines, panel edges, internal splitsTypography
The system stack. No webfont means no extra request, no FOUT and no layout shift — and on every platform the UI already looks native. Headings are fluid with clamp(); body sizes are fixed so the measure stays predictable.
Forward deployed engineering
Selected work
How I work
The opening paragraph under a heading. Wider type, tighter leading, and capped at 56 characters so it stays one comfortable block.
Body copy. Seventeen pixels is the baseline across the whole system — large enough to read on a phone without zooming, and the line height of 1.6 is what keeps a long paragraph scannable.
Secondary copy: card descriptions, table cells, form hints and captions.
Metadata, chips, badges, eyebrows and footnotes. Never body copy.
12,400 req/s
Negative letter-spacing scales with size: the bigger the type, the tighter it is set. That is what stops large headings from looking loose.
--font-sans
The quick brown fox jumps over the lazy dog — 0123456789
--font-mono
The quick brown fox jumps over the lazy dog — 0123456789
Mono is for code, identifiers, keys and anything the reader might retype. Not for decoration.
Space
A 4 px scale, named by step rather than by pixel value so the whole ramp can be retuned in one place. Use the named steps; never type a raw pixel gap.
--section-y) and the grid own gaps. Components stay margin-free so they compose anywhere.
margin-bottom on a component. The next thing to use it will need a different gap, and you will end up overriding it.
Radius
Radius steps with the size of the box: a chip is a pill, a small control is 10 px, a card is 20 px, a panel or modal is 28 px. A large radius on a small element reads as a bubble, not as software.
Shadow
Three depths, all extremely soft — this is a flat system with a hint of lift, not a material one. In dark theme all three resolve to none: on black a drop shadow reads as grime, so depth comes from --bg-elevated instead.
Property skins
A property may re-skin the system by redefining tokens in its own file, loaded after tokens.css. This is the strongest evidence a token system actually works — and tanghoong.com is the proof. It runs this exact token set, then overrides the palette to a deep petrol ground with a sage accent and a gold second voice. Every component still works, because not one of them names a colour.
/* skin.css — loaded AFTER tokens.css */
:root {
color-scheme: dark;
--bg: #071a24; --bg-sunken: #051620; --bg-elevated: #0b2733;
--text: #f5fafb; --text-2: #e6f2f5d1; --text-3: #a2b3b8; --text-hi: #fff;
--hairline: #dcf1f52e; --hairline-soft: #dcf1f51a;
--accent: #b8d9c9; --accent-2: #ffd38a;
--sh-sm: none; --sh-md: none; --sh-lg: none;
}
Note the hairlines: #dcf1f52e, not a neutral white alpha. On a coloured ground a neutral line reads as grey scum — it has to carry the ground's own hue.
--accent and its ink/wash, --accent-2, --fs-read, the container widths, and color-scheme if the property is single-purpose and deliberately one theme.
--ease and the durations, the status colours, the focus treatment, or the mark. Change those and it stops being the same system.
node scripts/contrast.mjs parses whatever light-dark() values it finds. A new palette is exactly where contrast quietly breaks, and a skin that has not been checked is a skin that has not been finished.
tokens.css in a consuming repo. That is a fork, and the next time the system changes you will find out the expensive way.
Motion
One easing curve for almost everything: cubic-bezier(.22, 1, .36, 1) — fast out, long settle. Three durations. Anything slower than --dur-slow feels broken.
| Token | Value | Use |
|---|---|---|
--dur-fast | 0.15s | Hover, active, colour changes |
--dur | 0.26s | Transforms, popovers, modals |
--dur-slow | 0.42s | Sheets and drawers travelling a long distance |
--ease | cubic-bezier(.22,1,.36,1) | Default for everything |
--ease-in | cubic-bezier(.4,0,1,1) | Things leaving the screen |
base.css reduces every animation and transition to 0.01 ms under prefers-reduced-motion: reduce. If you add an animation outside the token system, it will not be covered — check it.
Focus
One ring for the entire system: a 2 px accent outline with 2 px offset, on :focus-visible only. A mouse click never shows it; a keyboard tab always does. Never set outline: none without replacing it.
Themes
Three states: Auto follows the OS, and an explicit choice stamps data-theme on <html>. Every colour is declared once with light-dark(), so there is no second palette to keep in sync.
:root {
color-scheme: light dark; /* required — light-dark() needs it */
--bg: light-dark(#fbfbfd, #000000);
--text: light-dark(#1d1d1f, #f5f5f7);
}
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
Because the toggle only changes color-scheme, every light-dark() value flips at once. Adding a colour means adding one line, not two.
<button class="iconbtn" data-theme-toggle aria-label="Theme">
<svg class="ic ic--auto" …></svg>
<svg class="ic ic--light" …></svg>
<svg class="ic ic--dark" …></svg>
</button>
/* Auto is the default, so it needs no selector — the other two override it. */
.ic { display: none; }
.ic--auto { display: inline-flex; }
:root[data-theme="light"] .ic--auto,
:root[data-theme="dark"] .ic--auto { display: none; }
:root[data-theme="light"] .ic--light { display: inline-flex; }
:root[data-theme="dark"] .ic--dark { display: inline-flex; }
The visible icon is a function of the data-theme attribute, not of a variable in script. Swapping it from JavaScript is how a toggle ends up showing a sun while the page is in dark mode.