Design system
What this site is made of
One accent, four inks, three themes and sixteen primitives. Every value on this page is read out of the live stylesheet rather than copied into it, so this is a measurement of the system rather than a description of it.
- Colour tokens
- 34
- Themes
- 3
- Type pairings
- 6
- Primitives
- 16
Tokens live in src/styles/globals.css; primitives in src/components/common/. The conventions behind both are written up in docs/frontend-conventions.md.
Colour, in every theme at once
Each token is resolved live out of the DOM in all three themes, so what you see is what globals.css currently says — not a copy of it. Click any name to copy it.
Surfaces
The background ladder. Every plane on the site is one of these five — there is no sixth, and no ad-hoc tint.
Page background
Cards, dialogs, anything raised
Sidebar, recessed chrome
Hover fill
Selected / active fill
Borders
Two weights. The high-contrast one is for element boundaries you need to see; the default is for everything else.
Default hairline
Controls, scrollbar thumb
Ink
A four-step text ramp, tuned per theme. The contrast grid below shows where each step actually lands.
Headings, primary text
Body copy
Secondary / meta text
Decorative, placeholder
Accent
Teal, in five weights. `accent` fills, `accent-text` sits on backgrounds — they differ because the fill would fail contrast as text in the dark themes.
Fills, indicators, focus ring
Secondary accent
Links, accent text and icons
Tinted fill
Faintest tinted fill
Text on an accent fill
Semantic
Each status ships as a foreground/background pair, so a Panel never has to derive its own tint.
Success foreground
Success fill
Info foreground
Info fill
Warning foreground
Warning fill
Danger foreground
Danger fill
Code
The syntax palette. Light theme uses a high-contrast set; dim and dark share a softer one.
Code block background
Plain text
Comments
Keywords, tags
Functions, attributes
Strings
Constants, numbers
Operators, punctuation
Types
Using them
Tailwind v4's parenthesis shorthand takes a custom property directly. There is no colour scale in the Tailwind config to go through — the tokens are the scale.
bg-(--color-bg-panel)Backgroundstext-(--color-ink-2)Textborder-(--color-border)Bordersshadow-(--shadow-md)ShadowsContrast, measured
Every ink token against every surface token, computed in the browser with the WCAG 2.1 formula. This grid is the reason the ink ramp has four steps rather than however many looked nice — and it will go red the moment a token drifts.
Ink on surface
AA is the 4.5:1 floor for body text; AAA is 7:1. “AA Large” marks the 3:1 tier — a pass for headings at 18pt+, for bold text at 14pt+, and for UI boundaries, but not for body copy.
| bg | bg-panel | bg-sidebar | bg-hover | bg-active | |
|---|---|---|---|---|---|
| ink | — | — | — | — | — |
| ink-2 | — | — | — | — | — |
| ink-3 | — | — | — | — | — |
| ink-4 | — | — | — | — | — |
| accent-text | — | — | — | — | — |
ink-4 is expected to fail here — it is a decorative tier, used for placeholders and for labels that repeat information already carried by an adjacent element. Nothing that has to be read on its own should use it.
Elevation, shape and motion
Four shadows, five radii, and a small set of durations. The shadows are theme-aware — they get deeper and more opaque as the background darkens, because the same rgba would disappear against dark.
Elevation
Rendered on a real Surface, on the page background, in the theme you are in. Surface takes these by name via its elevation prop.
Resting cards, the default Surface
Hover lift, toasts, code playgrounds
Dialogs, popovers, the font menu
Recessed wells — SegmentedControl
Surface elevations
The same four steps as the Surface primitive exposes them.
elevation="none"elevation="sm"elevation="md"elevation="lg"Radius
Surface and Card expose sm through xl; full is for pills — buttons, chips and the status dot.
rounded-sm0.25remrounded-md0.375remrounded-lg0.5remrounded-xl0.75remrounded-full9999pxBorders
Two weights, plus the rule about where they belong: the content area carries the blueprint grid, so section dividers inside it clash. Borders live on panels, on the sidebar, and on controls.
Default hairline
Controls, higher contrast
Motion
Durations are literals in the components rather than custom properties. They are listed here so new code reaches for one of these speeds instead of inventing a sixth.
130msNav items, segmented control, font picker
150msTable-of-contents links, dialogs
180msButtons
200msCard lift
220msTheme change (background, border, colour)
status-pulseLive indicator dots — StatusDotcursor-blinkTypewriter caret — TypewriterTextAll of it collapses to 0.01ms under prefers-reduced-motion, which is handled once in globals.css rather than per component.
Type, on six switchable faces
Fraunces carries every heading and never changes. What the visitor can change is the face that carries the reading — and the mono that goes with it. Because the display face is fixed, the headings below look the same whichever pairing is active; only the body copy moves.
Display scale
Rendered by the Heading primitive. hero and page map to .hero-h1 / .page-title in globals.css; the numbered levels are Tailwind utilities.
level={'hero'}Fraunces · clamp(40px, 5vw, 64px) · 700 · -0.03em · 1.1Homepage hero onlyHandgloves
level={'page'}Fraunces · clamp(32px, 4vw, 50px) · 700 · -0.025em · 1.15Every page title, via PageHeaderHandgloves
level={1}Fraunces · 1.875rem → 3rem · 700In-content h1Handgloves
level={2}Fraunces · 1.5rem → 1.875rem · 700Section headingsHandgloves
level={3}Fraunces · 1.25rem → 1.5rem · 700Sub-sectionsHandgloves
level={4}Fraunces · 1.125rem → 1.25rem · 700Block headingsHandgloves
Text scale
The Text primitive. Each variant carries a default ink colour matching the hierarchy, overridable with the color prop.
variant="lead"1.125rem → 1.25rem · relaxedThe quick brown fox jumps over the lazy dog
variant="body"1rem · relaxedThe quick brown fox jumps over the lazy dog
variant="body-sm"0.875rem · relaxedThe quick brown fox jumps over the lazy dog
variant="caption"13px · normalThe quick brown fox jumps over the lazy dog
variant="caption-sm"11px · normalThe quick brown fox jumps over the lazy dog
Labels
Two small-caps treatments. The eyebrow is accent-coloured and sits above page titles; SectionLabel is the muted one, used inside panels and nav groups.
Eyebrow — 11px / 600 / 0.12em
.eyebrow<SectionLabel>Pairings
Picking one here changes the whole site immediately — this is the same store the sidebar's font picker writes to, not a preview. The reading size is one value shared by every face; only the leading is nudged per face, because x-heights differ enough to change how tight a paragraph feels at the same size.
Code
The mono face comes from the active pairing too. Syntax colours are the --code-* tokens, which is why a code block re-tints with the theme instead of staying dark on a light page.
/* The three faces, resolved from the active pairing */
--font-sans: var(--font-text-face), system-ui, sans-serif;
--font-serif: var(--font-display-face), Georgia, serif;
--font-mono: var(--font-mono-face), ui-monospace, monospace;The primitives
Everything in src/components/common. These are live — the same imports the rest of the site uses, rendering in whatever theme and pairing you have selected. If one of them looks wrong here, it looks wrong everywhere.
Surface
common/SurfaceThe container primitive: panel background, elevation, radius and an optional border. Card and every dialog build on it.
elevation="sm"elevation="md"elevation="lg"| Prop | Type | Default | Notes |
|---|---|---|---|
| elevation | 'none' | 'sm' | 'md' | 'lg' | 'sm' | Maps to the --shadow-* tokens. |
| rounded | 'sm' | 'md' | 'lg' | 'xl' | 'lg' | Corner radius. |
| border | boolean | true | Hairline in --color-border. |
| as | React.ElementType | 'div' | Semantic override. |
Card
common/CardSurface plus padding presets and the hover lift. Use hover for anything clickable — it is a CSS transition, not a JS mouse handler.
padding="md"
Resting card.
hover
Point at me.
| Prop | Type | Default | Notes |
|---|---|---|---|
| padding | 'none' | 'sm' | 'md' | 'none' | Inner spacing preset. |
| hover | boolean | false | Adds .card-hover — 1px lift plus --shadow-md. |
Chip
common/ChipCompact inline label in three sizes and three tones. Used for tags, tech stacks and status badges.
| Prop | Type | Default | Notes |
|---|---|---|---|
| variant | 'default' | 'highlight' | 'muted' | 'default' | Tone. |
| size | 'xs' | 'sm' | 'md' | 'sm' | 11px, 12px or 13px. |
Panel
common/PanelCallout block: semantic hairline, matching tint, and a marker dot. The colour never lands on text — half the semantic foregrounds fail AA on their own tint. Registered as an MDX component, so posts can use it directly.
Info
Neutral aside.
Success
It worked.
Warning
Mind this.
Danger
Do not do this.
Accent
Worth noticing.
| Prop | Type | Default | Notes |
|---|---|---|---|
| type | 'info' | 'warning' | 'danger' | 'success' | 'accent' | — | Picks the border colour, tint, and marker. |
| title | string | — | Bold heading line. |
SegmentedControl
common/SegmentedControlGeneric exclusive picker with an inset well. Drives the theme switcher, the chess time-control picker, and the contrast grid above.
| Prop | Type | Default | Notes |
|---|---|---|---|
| options | SegmentOption<T>[] | — | value, plus optional label, icon and title. |
| value | T | null | — | null renders nothing as selected. |
| onChange | (value: T) => void | — | Selection handler. |
StatusDot
common/StatusDotSmall indicator dot, pulsing by default. The colour is an inline style on purpose — it is a runtime value with no Tailwind equivalent.
| Prop | Type | Default | Notes |
|---|---|---|---|
| color | string | var(--color-success) | Any CSS colour. |
| pulse | boolean | true | Adds .animate-status-pulse. |
TextLink
common/TextLinkAccent-coloured navigation link over next/link. For inline prose links inside long-form content, the .fancy-link utility is the richer treatment.
A sentence with a TextLink in it.
And one with a fancy-link in it.
| Prop | Type | Default | Notes |
|---|---|---|---|
| href | string | — | Passed to next/link. |
Table
common/TableCompound table with a panel wrapper and horizontal scroll. Registered for MDX, so posts get consistent tables without markup gymnastics.
| Token | Purpose |
|---|---|
| --color-ink | Headings |
| --color-ink-2 | Body copy |
import { Table } from '@/components/common/Table';
<Table>
<Table.THead><Table.Tr><Table.Th>Name</Table.Th></Table.Tr></Table.THead>
<Table.TBody><Table.Tr><Table.Td>Value</Table.Td></Table.Tr></Table.TBody>
</Table>AnimatedNumber
common/AnimatedNumberCounts up to its value with an expo ease-out, and scrambles while the value is still undefined — which is what the homepage widgets show while their data is in flight.
| Prop | Type | Default | Notes |
|---|---|---|---|
| value | number | undefined | null | — | Nullish renders the scrambling loading state. |
| duration | number | 1.2 | Seconds. |
TypewriterText
common/TypewriterTextTypes its text out on entering the viewport, then blinks. Honours prefers-reduced-motion through the global rule.
| Prop | Type | Default | Notes |
|---|---|---|---|
| text | string | — | The full string. |
| charSpeed | number | 45 | Milliseconds per character. |
| active | boolean | — | Bypasses the IntersectionObserver and starts on true. |
Also in common/
Documented above under Foundations, since they are typography rather than components in their own right.
Layout and conventions
The rules that are not components: where padding comes from, when a border is allowed, and the handful of CSS utilities that exist because three or more places needed the same rule set.
Page padding
Every page picks one of these rather than writing its own padding. They all share the same 20 → 52px horizontal rhythm; what differs is the column width and the vertical space.
Standard content pages — About, Uses, this page
880px column · 24/20px padding → 40/52px at md
Blog post bodies, where the column is sized by the post layout
24/20px padding → 40/52px at md
Homepage hero, aligned to the widget grid below it
960px column · 32/20px padding → 64/52px at md
Homepage widget cards
2 columns → 3 at md · 10px gap · 960px column
Homepage "latest writing" list
960px column, matching widget-grid
Effects
Cross-component CSS in globals.css. The bar for adding one is three or more components sharing the same rule set — below that it stays a Tailwind string at the call site.
The grid behind the content area. Two layers — 96px primary over 32px faint — offset so the lines land off-centre.
Interactive card lift: 1px translate plus --shadow-md, in CSS rather than JS mouse handlers.
Inline links whose underline fills upward into a highlight on hover.
Escape hatch that freezes transitions, transforms and animations in a subtree.
Blueprint grid
The content area's background. Two line layers at different scales, offset from the origin so the grid does not line up with the content edge — which is what stops it reading as a table.
Borders and dividers
Because that grid is already drawing horizontal lines, a border-b inside the content area competes with it. The rule: separate sections with space, and keep borders for surfaces that have their own solid background.
Borders belong here
Panels and cards, the sidebar, and interactive controls — anywhere the border marks an element boundary against a solid fill.
Not here
Section dividers in the content area. Use gap, py or my instead — the grid behind is already doing that job.
Focus and selection
Both are set once, globally. Focus rings are never removed per component — where a control needs a different ring, it overrides the colour, not the visibility.
Then try selecting this text.
*:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
::selection {
background-color: var(--color-selection-bg);
color: var(--color-selection-text);
}Styling hierarchy
In priority order. Each step down needs a reason the step above could not carry.
- 1
Tailwind utilities
First choice for all layout, spacing, type and colour. Use the v4 shorthand: text-(--color-ink), not text-[var(--color-ink)].
- 2
globals.css utility class
When three or more components share a rule set that Tailwind cannot express cleanly.
- 3
goober css``
Only where pseudo-selectors or complex selectors are genuinely required.
- 4
Inline style
Motion values, and runtime values with no Tailwind equivalent. Never for static colour, spacing or type.