Skip to main content

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.

Foundations

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

light
dim
dark

Cards, dialogs, anything raised

light
dim
dark

Sidebar, recessed chrome

light
dim
dark

Hover fill

light
dim
dark

Selected / active fill

light
dim
dark

Borders

Two weights. The high-contrast one is for element boundaries you need to see; the default is for everything else.

Default hairline

light
dim
dark

Controls, scrollbar thumb

light
dim
dark

Ink

A four-step text ramp, tuned per theme. The contrast grid below shows where each step actually lands.

Headings, primary text

light
dim
dark

Body copy

light
dim
dark

Secondary / meta text

light
dim
dark

Decorative, placeholder

light
dim
dark

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

light
dim
dark

Secondary accent

light
dim
dark

Links, accent text and icons

light
dim
dark

Tinted fill

light
dim
dark

Faintest tinted fill

light
dim
dark

Text on an accent fill

light
dim
dark

Semantic

Each status ships as a foreground/background pair, so a Panel never has to derive its own tint.

Success foreground

light
dim
dark

Success fill

light
dim
dark

Info foreground

light
dim
dark

Info fill

light
dim
dark

Warning foreground

light
dim
dark

Warning fill

light
dim
dark

Danger foreground

light
dim
dark

Danger fill

light
dim
dark

Code

The syntax palette. Light theme uses a high-contrast set; dim and dark share a softer one.

Code block background

light
dim
dark

Plain text

light
dim
dark

Comments

light
dim
dark

Keywords, tags

light
dim
dark

Functions, attributes

light
dim
dark

Strings

light
dim
dark

Constants, numbers

light
dim
dark

Operators, punctuation

light
dim
dark

Types

light
dim
dark

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)Backgrounds
text-(--color-ink-2)Text
border-(--color-border)Borders
shadow-(--shadow-md)Shadows
Foundations

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

bgbg-panelbg-sidebarbg-hoverbg-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.

Foundations

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.25rem
rounded-md0.375rem
rounded-lg0.5rem
rounded-xl0.75rem
rounded-full9999px

Borders

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.

130ms

Nav items, segmented control, font picker

150ms

Table-of-contents links, dialogs

180ms

Buttons

200ms

Card lift

220ms

Theme change (background, border, colour)

status-pulseLive indicator dots — StatusDot
cursor-blinkTypewriter caret — TypewriterText

All of it collapses to 0.01ms under prefers-reduced-motion, which is handled once in globals.css rather than per component.

Foundations

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 only

Handgloves

level={'page'}Fraunces · clamp(32px, 4vw, 50px) · 700 · -0.025em · 1.15Every page title, via PageHeader

Handgloves

level={1}Fraunces · 1.875rem → 3rem · 700In-content h1

Handgloves

level={2}Fraunces · 1.5rem → 1.875rem · 700Section headings

Handgloves

level={3}Fraunces · 1.25rem → 1.5rem · 700Sub-sections

Handgloves

level={4}Fraunces · 1.125rem → 1.25rem · 700Block headings

Handgloves

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 · relaxed

The quick brown fox jumps over the lazy dog

variant="body"1rem · relaxed

The quick brown fox jumps over the lazy dog

variant="body-sm"0.875rem · relaxed

The quick brown fox jumps over the lazy dog

variant="caption"13px · normal

The quick brown fox jumps over the lazy dog

variant="caption-sm"11px · normal

The 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 — 11px / 600 / 0.08em
<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;
Components

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.

Button

common/Button

Polymorphic pill button in three variants. Pass as={Link} or as='a' to render a navigation control that still looks like a button.

Variants
Sizes
PropTypeDefaultNotes
variant'primary' | 'secondary' | 'ghost''primary'Visual weight.
size'sm' | 'md''md'13px or 14px, with matching padding.
asReact.ElementType'button'Render as Link, a, or any element.

Surface

common/Surface

The container primitive: panel background, elevation, radius and an optional border. Card and every dialog build on it.

elevation="sm"
elevation="md"
elevation="lg"
PropTypeDefaultNotes
elevation'none' | 'sm' | 'md' | 'lg''sm'Maps to the --shadow-* tokens.
rounded'sm' | 'md' | 'lg' | 'xl''lg'Corner radius.
borderbooleantrueHairline in --color-border.
asReact.ElementType'div'Semantic override.

Card

common/Card

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

PropTypeDefaultNotes
padding'none' | 'sm' | 'md''none'Inner spacing preset.
hoverbooleanfalseAdds .card-hover — 1px lift plus --shadow-md.

Chip

common/Chip

Compact inline label in three sizes and three tones. Used for tags, tech stacks and status badges.

Variants
DefaultHighlightMuted
Sizes
xssmmd
PropTypeDefaultNotes
variant'default' | 'highlight' | 'muted''default'Tone.
size'xs' | 'sm' | 'md''sm'11px, 12px or 13px.

Panel

common/Panel

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

PropTypeDefaultNotes
type'info' | 'warning' | 'danger' | 'success' | 'accent'Picks the border colour, tint, and marker.
titlestringBold heading line.

SegmentedControl

common/SegmentedControl

Generic exclusive picker with an inset well. Drives the theme switcher, the chess time-control picker, and the contrast grid above.

PropTypeDefaultNotes
optionsSegmentOption<T>[]value, plus optional label, icon and title.
valueT | nullnull renders nothing as selected.
onChange(value: T) => voidSelection handler.

StatusDot

common/StatusDot

Small indicator dot, pulsing by default. The colour is an inline style on purpose — it is a runtime value with no Tailwind equivalent.

AvailableBusyOfflinepulse=false
PropTypeDefaultNotes
colorstringvar(--color-success)Any CSS colour.
pulsebooleantrueAdds .animate-status-pulse.

Table

common/Table

Compound table with a panel wrapper and horizontal scroll. Registered for MDX, so posts get consistent tables without markup gymnastics.

TokenPurpose
--color-inkHeadings
--color-ink-2Body 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/AnimatedNumber

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

PropTypeDefaultNotes
valuenumber | undefined | nullNullish renders the scrambling loading state.
durationnumber1.2Seconds.

TypewriterText

common/TypewriterText

Types its text out on entering the viewport, then blinks. Honours prefers-reduced-motion through the global rule.

PropTypeDefaultNotes
textstringThe full string.
charSpeednumber45Milliseconds per character.
activebooleanBypasses the IntersectionObserver and starts on true.

Also in common/

Documented above under Foundations, since they are typography rather than components in their own right.

HeadingTextSectionLabelPageHeaderMarkDividerMDX
Patterns

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

    globals.css utility class

    When three or more components share a rule set that Tailwind cannot express cleanly.

  3. 3

    goober css``

    Only where pseudo-selectors or complex selectors are genuinely required.

  4. 4

    Inline style

    Motion values, and runtime values with no Tailwind equivalent. Never for static colour, spacing or type.