- Design system
- Components
04 — Components
Components
The working parts. Every one is plain HTML plus a class — no framework, no JavaScript unless the behaviour genuinely requires it.
Buttons
Five intents. Exactly one --filled per view — it is the primary action, and two primaries means neither is. Every variant clears 44 px; that is a touch-target requirement, not a style preference.
<button class="btn btn--filled" type="button">Filled</button> <button class="btn btn--tinted" type="button">Tinted</button> <button class="btn btn--quiet" type="button">Quiet</button> <button class="btn btn--outline" type="button">Outline</button> <button class="btn btn--danger" type="button">Danger</button>
Always set type on a button inside a form — the default is submit, and an unlabelled one will submit the form.
An icon-only button must carry aria-label; the SVG is aria-hidden. .iconbtn is the bordered header/toolbar variant.
<a class="link-more" href="/work">See all work <svg width="16" height="16" …><path d="M5 12h14m-6-7 7 7-7 7"/></svg> </a>
Chips and tags
A chip is a passive label — a topic, a stack item, a filter value. It carries no state. If it is clickable, make it an <a> or <button> and it picks up the hover treatment.
<div class="chips"> <span class="chip">Production</span> <span class="chip chip--outline">Outline</span> <span class="chip chip--accent">Accent</span> </div>
On a sunken section chips would disappear, so the CSS lifts them to --bg-elevated automatically inside .s-sunken, .card and .panel.
Badges
A badge carries state: status, severity, a count. Unlike a chip, its colour is meaningful — which is exactly why it must always be paired with a word. Colour alone fails for a colour-blind reader and for anyone on a monochrome display.
<span class="badge badge--ok"> <span class="badge__dot" aria-hidden="true"></span>Live </span>
Cards
An elevated surface for a self-contained item. When the whole card is a link, make the card itself the <a> — one hit target, one hover state, one tab stop. A card that is not a link should not lift on hover.
<div class="cards cards--3">
<a class="card" href="/work/bid-pipeline">
<span class="card__title">Realtime bid pipeline</span>
<span class="card__desc">Cut p95 from 240 ms to 38 ms…</span>
<span class="card__status">Delivered · 2024</span>
<span class="card__foot">
<span class="chips"><span class="chip">AdTech</span></span>
<svg class="card__arrow" …></svg>
</span>
</a>
</div>
.card__foot uses margin-block-start:auto, so a row of cards with uneven copy still lines its footers up.
Panels
The card's quieter sibling: a well that groups related content. No hover, no lift, no destination. Use it for settings groups, summaries and sidebars.
Two regions, blue/green, with automatic rollback on a p95 regression above 20%.
Use .panel--elevated when the panel sits on a sunken section and needs to lift instead of recede.
Notes and alerts
An inline message. The leading rule carries the intent colour and the wash tints the background — but the words still say what happened.
/index.md.src/api/.Put role="alert" on a note that appears in response to something the user just did. Do not put it on a note that was on the page at load — it will be announced for no reason.
Stats
Figures set in tabular numerals so digits line up in a column, with an uppercase label beneath. The figure is the headline; the label explains it.
12.4k
Peak req/s
Sustained, single region
38 ms
p95 latency
Down from 240 ms
40+
Pipelines
In production
15 yrs
Delivery
APAC
Row lists
Records separated by hairlines. Denser than cards and the right choice once you have more than about six items — a long grid of cards becomes hard to scan.
Definition lists
Key/value metadata for a case study, a spec sheet or a profile. Uppercase keys, regular values, in a sunken well.
- Role
- Forward deployed engineer, sole implementer
- Duration
- 11 weeks, discovery to production
- Stack
- Go, Postgres, Cloudflare Workers
- Status
- Delivered and merged
Tables
Always wrap a table in .table-wrap. On a phone the wrapper scrolls sideways; the page body never should. Numeric columns get .num for right alignment and tabular figures.
| Region | Status | p50 | p95 | Requests |
|---|---|---|---|---|
| Singapore | Live | 12 ms | 38 ms | 4,204,110 |
| Kuala Lumpur | Live | 14 ms | 41 ms | 2,880,004 |
| Sydney | Degraded | 28 ms | 190 ms | 612,900 |
| Frankfurt | Standby | — | — | 0 |
<div class="table-wrap">
<table class="table">
<caption>Latency by region</caption>
<thead><tr><th>Region</th><th class="num">p95</th></tr></thead>
<tbody><tr><td>Singapore</td><td class="num">38 ms</td></tr></tbody>
</table>
</div>
Add .table--compact for dense data views. Keep the <caption> — it is the table's accessible name.
Lists
Plain lists
- Markers use
--text-3so they sit behind the text - Siblings are spaced with
--s-2 - Nested lists inherit the same rhythm
- Second level
- Still readable
- Discovery
- Thin vertical slice
- Production hardening
Numbered steps
Frame the problem
Write the failure mode down before writing code.
Ship a thin slice
One real path, end to end, into production.
Instrument, then widen
Measure before adding the second path.
Tabs, breadcrumbs, meters
Real tabs need arrow-key navigation and aria-controls wiring. If you only need to switch views, links to separate pages are simpler and more robust.
or
No results
Nothing matched that filter. Try widening the date range.