Skip to content

Design System

The tokens and components behind this portfolio.

Almost nothing here is new. The scale, the rhythm, and the two button treatments were already in the code — they were just spelled out longhand, a few hundred times, with no names. This page is the result of giving them names and then proving the names render identically.

Foundations

Flips the bordered canvases below, not the page.

Dark is scoped on purpose. A global flip would paint the contact modal’s near-white text onto its hardcoded white panel, and the pastel card washes are baked JPEGs that CSS can’t invert. Those surfaces get tokenised first; then the selector widens.

Colour

Named for the job, never the hue. There is no --color-gray-100 here, which is why the dark theme is a redefinition of nine variables rather than a rewrite of every component. Each swatch below is painted with its own token, so it re-paints when you toggle; both values are printed because one swatch can only show you one of them.

Text

A four-step ladder from primary to faint. Headings sit at the top, secondary copy steps down. Picking a step is a decision about hierarchy, not about grey.

  • text-ink--color-ink#1a1a1a #f2f2f2 (light and dark values)

    Headings and primary copy. The default text colour.

  • text-ink-soft--color-ink-soft#444444 #c4c4c4 (light and dark values)

    Body copy under a heading. Long-form reading.

  • text-muted--color-muted#666666 #9a9a9a (light and dark values)

    Secondary and meta copy — dates, roles, captions.

  • text-faint--color-faint#888888 #7a7a7a (light and dark values)

    The quietest step. Footer credit row.

  • text-on-ink--color-on-ink#ffffff #121213 (light and dark values)

    Foreground on a filled --color-ink surface. Flips dark in dark mode — which is exactly why it isn't called `white`.

Surface

What things sit on. `page` is the floor; `fill` and `surface` lift off it. Nothing here is pure white — that's a deliberate softness in the brand.

  • bg-page--color-page#fafafb #121213 (light and dark values)

    The page floor. Set once on <body>.

  • bg-fill--color-fill#eeeeee #1f1f21 (light and dark values)

    Quiet filled controls — secondary buttons, image wells.

  • bg-surface--color-surfacergba(255,255,255,0.8) rgba(255,255,255,0.06) (light and dark values)

    Raised cards. Translucent so the page tone reads through rather than sitting on top of it.

  • bg-hero-wash--color-hero-wash#e4eefb #1b2637 (light and dark values)

    The tinted banner behind a case-study hero screenshot.

  • bg-scrim--color-scrimrgba(0,0,0,0.2) rgba(0,0,0,0.5) (light and dark values)

    The backdrop behind a modal or the lightbox. Deeper in dark mode, where a 20% scrim would barely register.

Line

One border colour. If a hairline needs to be quieter, lower its opacity — don't invent a second grey.

  • border-border--color-border#e5e5e5 #2f2f31 (light and dark values)

    Hairlines, dividers, field outlines, image-well edges.

State

The only two colours in the system. Both earn their place by meaning something — never use them decoratively.

  • text-accent--color-accent#0062e2 #6ea8ff (light and dark values)

    Links, focus rings, ghost-button hover. Lifted in dark mode — the light-mode blue fails contrast on a dark page.

  • text-danger--color-danger#cb0004 #ff6b6e (light and dark values)

    Form validation only. Nothing else in the system is red.

Type

Thirteen steps, every one a multiple of four, every one already in the codebase before it had a name. Martina Plantijn sets display; ABC Monument Grotesk sets everything else. The scale steps at md and call sites pair the steps — text-title md:text-display.

Note text-label and text-body-lg: both 20px, different leading. That pair is the whole argument for naming a scale by role — a token called text-20 could only ever have expressed one of them.

text-display64/72Serif

Design that ships

Page h1 and the closing h2, from md up.

text-title48/56Serif

Design that ships

Page h1 on mobile. Pairs with text-display at md.

text-heading40/48Serif

Design that ships

Section h2 on mobile.

text-subhead36/40Serif

A section heading

Case-study section h2, from md up.

text-card32/40Serif

A card title

Card h3 from md up; modal titles.

text-sub28/32Serif

A card title

Card h3 and case-study h2 on mobile.

text-lead24/32Sans

A short standfirst under a heading.

The closing subtitle, from md up.

text-body-lg20/28Sans

Body copy, one step up for comfortable desktop reading.

Body copy from md up. Roomier leading than text-label.

text-label20/24Sans

Recent Experience

Bold structural section labels. Same size as text-body-lg, tighter leading — the reason this scale is named by role and not by pixel.

text-subtitle18/24Sans

A short standfirst under a heading.

The closing subtitle on mobile.

text-body16/24Sans

The workhorse. Most copy on the site is set at this step.

Default body copy, buttons, pills. The most-used step by far.

text-body-sm14/20Sans

Secondary copy — dates, roles, tags.

Meta and secondary copy. Badge text.

text-caption12/16Sans

Fine print and form errors.

The smallest step. Form validation messages.

Space & radius

One rhythm and four radii. The rhythm was previously retyped as gap-[64px] md:gap-[120px] on every route; the radii are named after the thing they belong to, so rounded-card reads as intent instead of arithmetic.

Section rhythm

  • gap-section64px

    Vertical rhythm between page sections, on mobile.

  • gap-section-lg120px

    The same rhythm from md (768px) up.

Radius

  • rounded-card16px

    Cards, image wells, gallery figures. The default radius.

  • rounded-field12px

    Inputs and field shells.

  • rounded-panel24px

    A modal floating as a panel, from sm up.

  • rounded-sheet40px

    The same modal docked as a bottom sheet on mobile.

Components

Button

Fires an action. Two independent axes: variant carries colour, size carries padding. They never reach into each other, which is why the component has no conditional logic in it at all.

Variant

Size

Disabled

PropTypeDefaultNotes
variant"primary" | "secondary" | "ghost""primary"Colour only.
size"cta" | "sm" | "icon""cta"Padding only.
childrenReactNodeLabel, icon, or both — a gap is applied between them.
classNamestringMerged last via cn(), so callers can always override.
…restButtonHTMLAttributesonClick, disabled, aria-* all pass straight through. type defaults to "button", never a surprise submit.

Use it for

  • primary — the one action you most want taken on a screen. Aim for one per view.
  • secondary — a real but lesser action sitting beside a primary.
  • ghost — icon-only controls and low-emphasis actions, like a modal close.
  • size="icon" — any icon-only button. The negative margin keeps the 40px hit area from shoving neighbours around.

Don’t

  • Navigation. If it changes the URL it's a PillLink (an <a>), not a Button. This is the split that matters most in the system.
  • Two primaries side by side — if everything is emphasised, nothing is.
  • Reaching for className to recolour it. If a new treatment is needed often enough to hand-roll, it's a variant.

Badge

A small, non-interactive label. It renders a <span> and takes no onClick — if it can be clicked, it isn't a Badge.

Subtle

Agentic AIDesign SystemsPrototyping

Glass

Case study
PropTypeDefaultNotes
variant"subtle" | "glass""subtle"Carries colour, padding and type together.
childrenReactNodeShort label. A word or two.
classNamestringMerged last via cn().

Use it for

  • subtle — categorising a card's content, e.g. an experience tag.
  • glass — a label that has to read on top of imagery. The blur and white hairline are what separate it from the photo.

Don’t

  • Anything clickable or focusable.
  • Long text. A badge that wraps has become a paragraph.
  • glass on a flat background — the blur has nothing to do and it just looks murky.

ThemeToggle

Flips light/dark. Writes data-theme to <html>, mirrors it to localStorage, and reads the DOM back on mount rather than holding its own truth — so the two can't drift apart.

This is the same component as the one at the top of the page — and toggling either updates both, because neither holds the theme. The DOM does; they read it back.

PropTypeDefaultNotes
classNamestringThe only prop. Everything else is behaviour.

Use it for

  • Once per page. Two toggles would be two sources of truth.
  • Anywhere the surfaces below it opt into theming with data-design-surface.

Don’t

  • Treating it as a form control. It's a <button aria-pressed>, because it acts immediately rather than staging a value for submission.
  • Shipping it into the site header today — the dark tokens are scoped to this page's canvases until the rest of the site is tokenised.

Accordion

Progressive disclosure, generalised from the About page's experience groups. Composition over configuration: it takes children, not an items array, so a panel can hold anything.

Items start expanded so the content is readable before JS runs. Hydration only ever adds the ability to collapse — it never hides anything that was visible.

Open this while the others are open. The container owns no state, so items don’t close each other.

Height animates by tweening grid-template-rows from 0fr to 1fr — no JS measurement, no layout thrash. The panel stays mounted so it can tween, but is inert when closed, keeping it out of the tab order and the accessibility tree.

PropTypeDefaultNotes
Accordion.childrenReactNodeAccordionItems. The container owns no state.
AccordionItem.titleReactNodeThe always-visible trigger label.
AccordionItem.childrenReactNodePanel content. Stays mounted so the height can animate.
AccordionItem.defaultOpenbooleantrueStarts expanded so content is readable before JS runs — JS only ever adds the ability to collapse.
classNamestringOn either component.

Use it for

  • Long reference content a reader wants to skim and then drill into.
  • Independent sections — several can be open at once by design.

Don’t

  • Hiding anything essential. Collapsed content is content most people never read.
  • Single-open behaviour — it isn't built. That needs shared state in the container, and nothing on the site has ever needed it.

Known drift

What the system doesn’t cover yet, and where the code still disagrees with itself. A page claiming everything is finished would be the least believable thing here — and every item below is a decision someone has to make, not a bug to quietly patch.

It predates these variants and calls the same two treatments dark and light. That naming stops working the moment a theme exists — a "dark" button in dark mode is light. Button uses primary/secondary; PillLink should follow.

The tokens are real and the home page is migrated end to end, but every other route still writes text-[16px] leading-[24px] by hand. The scale is proven; the sweep is mechanical and unfinished.

The Connect line on the home page is 14px on 30px leading, where everywhere else 14px pairs with 20px. It's preserved as-is rather than quietly rounded — changing it is a design call, not a refactor.

CaseStudyNav uses white/50; FeaturedCaseStudyCard uses rgba(241,241,241,0.5). Badge variant="glass" adopts the former as canonical. The latter hasn't been migrated.

Their light pill is bg-[#eeeeee] hovering to black 5%, while PillLink's is bg-fill hovering to black 4%. Same intent, three spellings, and an off-by-one nobody meant to ship. This is the clearest argument in the codebase for tokens.

Only [data-design-surface] opts in. A global flip would paint the contact modal's near-white text on its bg-white panel, and the pastel JPEG card washes can't be inverted by CSS at all. Those surfaces have to be tokenised before the selector can widen.

Its only consumer was ContactModalTrigger, reached through CaseStudyHeader — both of which turned out to be unreachable and have now been deleted. So Button is currently exercised only on this page. PillLink still does the real work; the two should converge, with PillLink adopting primary/secondary naming.