Design system

The rules behind every build.

This is the actual system this site runs on — not a picture of one. Every swatch, ratio and specimen below is rendered by the same stylesheet as the rest of the site, so it cannot fall out of date. Each section says what the rule is and why it exists.

01 — Colour

Two layers, and the difference is the whole trick

Colours are defined twice: once as raw paint, once as a job. Components only ever ask for the job.

Palette — raw paint

These never change between themes. Nothing outside the token block is allowed to reference them directly, because a component that names a colour has already decided what it looks like in every theme that will ever exist.

--c-inkNear-black, warm. Headings and buttons.
--c-boneWarm paper. The page itself.
--c-bone-2The alternating section tint
--c-blue-deepThe accent, light theme
--c-blue-paleThe accent, dark theme
--c-greySecondary text, warm
--c-amberRating stars
--c-greenConfirmation, "live" markers

Semantic tokens — the job

Every ratio below is computed in your browser, right now, against the theme you are actually looking at. Switch the theme with the button in the header and watch them recalculate. The threshold shown is the one that applies to how that token is used — 4.5:1 for body-size text, 3:1 for large text and for graphics.

--textBody copy, 18px
--text-mutedSupporting copy, captions
--headingDisplay type and emphatic labels
--accentSection numerals, link hover, focus
--btn-bg / --btn-fgPrimary button, 15.2px/500 — not large text
--ok-text"Yes" in a table, live badges
--border-inputThe rule under a form field
--borderHairlines — the main structural device
--bgThe page ground
--bg-altAlternating section tint
--feature-bgInverted bands and the footer
--starRating stars — a graphic that carries meaning
Why two layers. A dark theme built this way is about forty-five lines, because not one component rule has to be repeated — a card that asks for --surface is already correct in both themes. The alternative, overriding components inside a [data-theme="dark"] block, means every new component needs a second rule written for it, and the day someone forgets is the day the site has a white box in the middle of a dark page.
Why the buttons are ink, not brand blue. A saturated blue button is the single most recognisable template tell on the web. Setting the primary action in ink on bone does the same job with more authority, and it leaves the accent free to actually mean something: it appears on section numerals, on a link you are hovering, and on a focus ring. A colour used everywhere stops being a signal.
Why --border and --border-input are separate tokens. Hairlines are the main structural device here, and a decorative rule between two blocks is exempt from contrast rules — it is not carrying information. The rule under a form field is the opposite: it is the only thing indicating a field is there at all, so it has to clear 3:1 against the page. The same visual idea with two different obligations, so: two tokens.
02 — Themes

Three states, not two

Light, dark, and whatever your computer is set to — which is the default.

StateWhat is storedWhat decides the look
Auto (default) nothing Your OS setting, through prefers-color-scheme. Change it in Windows or macOS and this page follows without a reload.
Light sx-theme = "light" data-theme="light" on <html>, which beats a dark OS.
Dark sx-theme = "dark" data-theme="dark", which beats a light OS.
Why "auto" stores nothing. Absence of a stored value is the auto state. Writing "auto" as a third value would mean the CSS had to handle a case the cascade already handles for free, and it would make "follow my system" indistinguishable from "this visitor has never chosen", which are different facts.
Why the theme script is a separate file loaded in <head>. It has to run before the first paint, or a visitor who chose dark gets a white flash — the single most obvious cheap-website tell there is. That rules out putting it with the rest of the JavaScript, which is deferred. It also cannot be an inline <script>, because this site sends script-src 'self' with no 'unsafe-inline' and the browser would refuse to run it. So it is one 30-line same-origin file that deliberately blocks rendering, and nothing else does.
Why the OS is followed live, but only sometimes. If you have never touched the toggle, changing your system theme changes this site immediately. If you have, it does not — overruling a choice someone made by hand is not a feature.
03 — Typography

A serif that sets things, a sans that says them

Instrument Serif carries the display sizes. Inter carries every word anyone actually has to read.

H1 · 42–77px · serif Your new business deserves a real website
H2 · 30–50px · serif What you actually get
H3 · 18–20px · sans Fixed price, agreed up front
Lede · 17–21px The sentence under a heading that decides whether anyone reads the rest of the section.
Body · 18px Body copy sits at 18 pixels with a 1.7 line height. Bigger than the 16px default on purpose: the people reading this are business owners on a phone, often outdoors, and the cost of a slightly larger body size is nothing.
Eyebrow · 11.5px Section label
Why clamp() instead of breakpoints. Every heading interpolates smoothly between a phone minimum and a desktop maximum, so there is no window width where type is awkwardly large or suddenly jumps. It also means one declaration per level instead of the same rule restated inside three media queries, which is three chances to forget one.
Why the serif stops at h2. Instrument Serif is a high-contrast face: the thin strokes that make it beautiful at 77 px begin to disappear below about 24 px. So it sets h1 and h2 and nothing else — h3 and every label drop back to Inter. Using a display face for a 19 px sub-heading is choosing prettiness at exactly the size where legibility matters most. All three files are self-hosted and latin-subset (Inter variable 47 KB, the serif 21 KB plus 21 KB italic), so no third-party stylesheet sits on the critical path.
text-wrap: balance on every heading. It stops the last line of a headline being a single orphaned word, which is the difference between a headline that looks set and one that looks like output.
04 — Space & shape

Rhythm you notice only when it is missing

Corner radius

--r-sm
2px
--r
3px
--r-lg
4px
pill
toggle only

Elevation

--shadow-sm
resting
--shadow
hovered
--shadow-lg
lifted

Section rhythm

gutter
card pad
grid gap
head gap
section
TokenValueJob
--maxw1240pxReading column ceiling. Wider than this and line length beats layout.
--gut26pxThe one horizontal inset. Every full-bleed section still uses it inside .wrap.
.section72 → 152pxVertical padding, fluid. Sections breathe more on desktop and less on a phone, without a breakpoint.
.narrow800pxCentred prose. Roughly 75 characters at body size, which is where reading speed peaks.
Why the corners are nearly square. A 22 px radius reads friendly-software; 3 px reads printed. The whole radius scale collapsed to 2–4 px in the editorial pass, and the only fully round things left are the theme toggle and the rating dots — shapes that are round because they are round, not because everything is.
05 — Motion

Three tiers, three different mechanisms

Hover a card below to run its curve.

--ease-out
cubic-bezier(.16, 1, .3, 1) · the default
--ease-spring
cubic-bezier(.34, 1.4, .64, 1) · overshoots
--ease-in-out
cubic-bezier(.65, 0, .35, 1) · ambient loops
linear
for comparison · never used for UI
TierMechanismDurationUsed for
EntranceCSS @keyframes + animation-fill-mode: both1150msAbove-the-fold content on load. Pure CSS, so it cannot leave anything hidden.
RevealIntersectionObserver adds [data-reveal]1050msEverything below the fold, staggered up to 5 steps.
InteractionCSS transition200–380msHover, focus, press. Anything slower reads as lag, not polish.
AmbientInfinite @keyframes26–46sThe drifting hero glow, the floating mock, the marquee. Slow enough to never pull the eye.
The rule that matters most: the hidden state is added by JavaScript, never written into the HTML. [data-reveal] is applied at runtime, so a visitor with no JavaScript — or one whose observer never fires — sees a completely normal page. Ship opacity: 0 in the stylesheet against markup that is already there and one failed script turns the whole page blank.
Reduced motion is honoured globally. One media query flattens every animation and transition on the site to 0.01ms and forces revealed content visible, including animations added later. It is a kill-switch, not a per-component opt-in, because per-component opt-ins get forgotten.

Do — animate transform and opacity

Both are composited by the GPU and never trigger layout. The scroll progress bar uses scaleX and is coalesced into requestAnimationFrame, so it costs nothing on a phone.

Don't — animate width, height or top

Every frame forces a reflow of everything below it. It is the difference between a bar that glides and a page that stutters while you scroll.

06 — Components

The pieces, as they actually render

Buttons

Primary Ghost Large primary
One primary button per view. A page with three equally weighted calls to action has none. The ghost variant exists so a secondary path can be present without competing — and on a phone the header's primary button is removed entirely, because the hero already carries one and a top bar that overflows sideways is worse than a missing button.

Cards

Card with hover

Lifts 4px, deepens its shadow, and tracks a soft highlight under the cursor.

Static card

Same surface and border, no interaction. Used where the card is not a link or target.

Concept build Live product

Badges

Two states with real meaning on the work page: is this a paying client's site, or mine.

Form fields

Focus it — the ring is 4px of accent at 14% opacity, plus a border change.
Focus is never removed, only replaced. Every interactive element gets a 3px :focus-visible outline in the accent colour with 2px of offset. It appears for keyboard users and stays out of the way for mouse users, which is exactly the behaviour :focus-visible exists to provide.

Tags

  • Next.js 16
  • Supabase
  • Stripe
  • Static HTML
  • Local SEO
07 — Accessibility

Checked with numbers, not by eye

Contrast is tested at the size it is used. A token is not "AA" in the abstract. 4.5:1 applies to body-size text; 3:1 applies to large text (18.66px bold or 24px regular) and to graphics. Testing a colour against the easier threshold and then using it at 15px is the most common way a palette passes a review and still fails a reader. Every pair in this system is scored against the threshold for its real usage — 50 pairs, both themes, zero failures.
A scan of this site reports contrast failures that are not real. Content arrives through a fade, so a block that has not finished revealing computes as partly transparent, and automated checkers score transparent text as failing contrast. Two things follow. The count moves whenever the page gets longer or shorter with no change to any colour, so the only safe reading is to diff violation targets against a baseline rather than compare totals. And because the entrance animations here are deliberately slow, a scanner can catch an element mid-fade — those findings are frames, not defects, and the fix is to re-measure the settled page rather than to flatten the colour until the scanner stops complaining.

Do — describe what an image shows

The alt text on the work page says what is in the screenshot, not that it is a screenshot. Decorative marks, like the logo beside the wordmark, take an empty alt so they are skipped entirely.

Don't — label a control with its state

The theme button says "Switch to dark theme", not "Dark theme". A label that names the current state leaves a screen reader user guessing whether it is a description or a promise.

CheckHow it is enforced
Colour contrastA script scores every token pair against the threshold for its real usage, in both themes, before anything ships.
Keyboard reachA skip link first in the DOM; :focus-visible on everything interactive; Escape closes the mobile menu and returns focus to the button that opened it.
Reduced motionOne global media query, so it also covers animations added after the fact.
Horizontal overflowMeasured at 390, 768, 1280 and 1600 by comparing scrollWidth to clientWidth — the most common mobile defect, and invisible in a desktop browser.
Text zoomType is set in rem and containers in ch/%, so a visitor at 200% zoom gets bigger text rather than a broken layout.

This is the standard your site gets

Every build starts from this system — which is why a five-page site can be finished in days without cutting the parts nobody sees.