tanghoong design
  1. Design system
  2. 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.

--bg

#fbfbfd / #000000
Page ground

--bg-sunken

#f5f5f7 / #0a0a0c
Section bands, wells

--bg-elevated

#ffffff / #1c1c1e
Cards, menus, modals

--bg-glass

72% + blur
Sticky header only

Why elevated is lighter on light and lighter on dark On light the page is off-white and cards are pure white; on dark the page is pure black and cards are a lifted grey. In both themes elevation means more contrast against the page, which is why a single 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.

--text

#1d1d1f / #f5f5f7
Body and headings

--text-2

#515154 / #a1a1a6
Supporting copy

--text-3

#6e6e73 / #86868b
Labels, metadata

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.

--accent

#03744e / #4dff9b

--accent-ink

#ffffff / #04140b
Text on a filled accent

--accent-wash

accent at 12%
Tinted buttons, active rows

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.

--ok

Success, live, delivered

--warn

Caution, pending

--danger

Errors, destructive

--info

Neutral notices

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.

Hairlines
--hairline — table rules, section dividers, input borders
--hairline-soft — card outlines, panel edges, internal splits

Typography

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.

Scale

--fs-h1clamp(2rem, 1.45rem + 2.6vw, 3.25rem)660 / 1.08 / -0.026em

Forward deployed engineering

--fs-h2clamp(1.5rem, 1.25rem + 1.2vw, 2.25rem)620 / 1.15 / -0.021em

Selected work

--fs-h3clamp(1.25rem, 1.15rem + 0.5vw, 1.5rem)600 / 1.3 / -0.014em

How I work

--fs-leadclamp(1.125rem, 1.02rem + 0.5vw, 1.375rem).lede

The opening paragraph under a heading. Wider type, tighter leading, and capped at 56 characters so it stays one comfortable block.

--fs-body17px400 / 1.6 / -0.006em

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.

--fs-sm15px.sm

Secondary copy: card descriptions, table cells, form hints and captions.

--fs-xs13px.xs

Metadata, chips, badges, eyebrows and footnotes. Never body copy.

--fs-statclamp(1.25rem, 0.95rem + 1.5vw, 1.9375rem)tabular-nums

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.

Families

--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.

Scale
--s-14px
--s-28px
--s-312px
--s-416px
--s-520px
--s-624px
--s-832px
--s-1040px
--s-1248px
--s-1664px
--s-2080px
--s-2496px
--s-32128px
Do Let the section own vertical rhythm (--section-y) and the grid own gaps. Components stay margin-free so they compose anywhere.
Don't Put 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.

--r-sm · 10px
--r-md · 16px
--r-lg · 20px
--r-xl · 28px
--r-chip · pill

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.

--sh-sm
--sh-md
--sh-lg

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.

The tanghoong.com skin
/* 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.

A skin may redefine The four surfaces, the four inks, both hairlines, --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.
A skin must not touch The space scale, the radius scale, --ease and the durations, the status colours, the focus treatment, or the mark. Change those and it stops being the same system.
Re-run the contrast check against a new palette 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.
A skin is a separate file Never edit 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.

TokenValueUse
--dur-fast0.15sHover, active, colour changes
--dur0.26sTransforms, popovers, modals
--dur-slow0.42sSheets and drawers travelling a long distance
--easecubic-bezier(.22,1,.36,1)Default for everything
--ease-incubic-bezier(.4,0,1,1)Things leaving the screen
Reduced motion is not optional 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.

Tab through these
A link

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.

Declaring a colour token
: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.

The toggle button — three icons, one visible
<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.