Design system
A small system, built to scale.
Every page here is made from the same few decisions, so a new page, component or theme adds no new rules and ships fast. Each decision is a token defined once and named by its job; components only use tokens, and motion is opt-in through attributes. That makes the system the groundwork for AI workflows too: the rules are explicit and written down, so an AI agent builds within the system instead of around it. The values on this page are read from the stylesheet at build time, so the documentation grows with the system.
Principles
One accent
Black and white with a single lime. Colour is reserved for what matters: links, focus, kickers, highlights.
Light type, quiet emphasis
Inter Light for display. Emphasis is a dimmed second voice in the same weight, not bold, italics or a second family.
Roles, not colours
Tokens describe a job (--ink, --muted, --accent). Dark mode swaps the values; no component knows which theme it is in.
Motion is optional
Every effect is an enhancement. Without JavaScript or with reduced motion, content is complete and still.
Colour
Ten roles, each with a light and a dark value, all written in oklch: the first number is perceived lightness, so values compare at a glance across themes. Lines are translucent so they work on any surface. The accent has three jobs in light mode, because the lime itself is too pale for text on white: --accent for text (passes AA), --accent-glow for decoration, --accent-fill as a surface. In dark mode all three are the lime.
--bgoklch(98.5% 0 0)oklch(0% 0 0)--surfaceoklch(100% 0 0)oklch(14.5% 0 0)--inkoklch(14.5% 0 0)oklch(100% 0 0)--ink-2oklch(36% 0 0)oklch(85.5% 0 0)--mutedoklch(52.8% 0 0)oklch(63.3% 0 0)--lineoklch(0% 0 0 / 0.12)oklch(88.5% 0 0 / 0.2)--line-strongoklch(0% 0 0 / 0.24)oklch(88.5% 0 0 / 0.38)--accentoklch(52.9% 0.152 134)oklch(92.4% 0.147 123.2)--accent-glowoklch(73.2% 0.212 134.5)oklch(92.4% 0.147 123.2)--accent-filloklch(92.4% 0.147 123.2)oklch(92.4% 0.147 123.2)
Typography
Two families. Inter carries everything readable, in Light (300) for display and Regular for text; JetBrains Mono is only for kickers and labels. Sizes are a fluid scale: each step grows with the viewport between a minimum and a maximum, so there are no breakpoint jumps.
--font-sansInter Variable Aa'Inter Variable', ui-sans-serif, system-ui, …--font-displayInter Variable Aavar(--font-sans)--font-monoJetBrains Mono 0123'JetBrains Mono Variable', ui-monospace, 'SF…
--step-4Designing progressclamp(2.25rem, 1.6rem + 3vw, 4.25rem)--step-3Designing progressclamp(1.75rem, 1.4rem + 1.6vw, 2.75rem)--step-2Designing progressclamp(1.4rem, 1.25rem + 0.7vw, 1.8rem)--step-1Designing progressclamp(1.15rem, 1.08rem + 0.35vw, 1.3rem)--step-0Designing progressclamp(1rem, 0.96rem + 0.2vw, 1.0625rem)--step--1Designing progressclamp(0.83rem, 0.8rem + 0.1vw, 0.875rem)--step--2Designing progress0.6875rem
Space and shape
The page breathes through a fluid gutter and generous section padding rather than a spacing scale. Shapes are either barely rounded (images) or fully round (pills).
Layout
Content sits in a centred column of at most --max, inset by a fluid gutter that grows with the viewport.
--gutter- clamp(16px, 4vw, 48px)clamp(16px, 4vw, 48px)
--max- 1240px1240px
Radius
Two shapes only: images barely rounded, pills fully round.
--r-image- 4px4px
--r-pill- 100px100px
- Hover me
Glass
Cards and ghost buttons. Hover deepens the border and the inner glow.
--glass- radial-gradient(140% 180% at 22% -20%, oklch…radial-gradient(140% 180% at 22% -20%, oklch…
--glass-border- oklch(0% 0 0 / 0.1)oklch(100% 0 0 / 0.1)
--glass-border-hover- oklch(0% 0 0 / 0.2)oklch(100% 0 0 / 0.22)
--glass-glow- inset 0 0 20px oklch(0% 0 0 / 0.04)inset 0 0 20px oklch(100% 0 0 / 0.1)
--glass-glow-hover- inset 0 0 28px oklch(0% 0 0 / 0.1)inset 0 0 34px oklch(100% 0 0 / 0.35)
Button
The primary pill: solid in light mode, glass with a strong glow in dark mode.
--btn-bg- oklch(14.5% 0 0)var(--glass)
--btn-ink- oklch(100% 0 0)oklch(100% 0 0)
--btn-border- oklch(0% 0 0 / 0.9)oklch(100% 0 0 / 0.14)
--btn-glow- inset 0 0 20px oklch(100% 0 0 / 0.18)inset 0 0 34px oklch(100% 0 0 / 0.35)
--btn-glow-hover- inset 0 0 28px oklch(100% 0 0 / 0.34)inset 0 0 40px oklch(100% 0 0 / 0.5)
Case study
Kicker
A lime chip in light mode; glowing lime text without a chip in dark mode.
--kicker-ink- oklch(14.5% 0 0)oklch(92.4% 0.147 123.2)
--kicker-bg- var(--accent-fill)transparent
--kicker-pad- 0.3em 0.5em 0.25em0
--kicker-glow- none0 0 14px oklch(92.4% 0.147 123.2 / 0.45)
- Case studyExample projectCover image coming
Tint and grain
How strongly a project hue washes its cover, and the film grain over every page.
--cover-tint- 0.140.2
--grain-opacity- 0.070.06
Components
Each component does one job and takes its look only from tokens. Live examples below render with the same code as the rest of the site.
Button
.btn · .btn-ghostGlass pill. Primary is the solid one per group; ghost buttons sit beside it. Hover deepens the inner glow instead of changing colour.
Case study
Client · 2025
Kicker
.eyebrow · .mono-labelJetBrains Mono in caps. Labels a section or a fact. A lime chip in light mode, glowing lime text in dark mode.
Designing progress, with confidence.
Display headline
.display + <em>Inter Light with tight tracking. The <em> part is the dimmed second voice.
Tags
.tag-listOutlined pills for skills, tools or scope. Information only, never interactive.
Divider
data-rule="top|bottom"A hairline drawn left to right as it scrolls into view. Colour and weight via --rule-c and --rule-w.
- Case studyExample projectCover image coming
Cover
<Cover client hue cover? />Case study image in a glass card. Without an image it falls back to a typographic card tinted in the project’s hue.
Header · Footer
Site frame. The brand plus three links; the footer carries legal pages and this page.
Work mosaic
Selected work as a grid: the lead tile full width, two beneath it. Each tile carries the cursor-trail shader.
Contact
Closing section on Home and About: display headline over a dither field, then the button group.
Protected
Shows email, phone or address without putting them in the HTML. Decoded in the browser to keep simple scrapers out.
Motion and effects
Components never animate themselves. They opt in with a data attribute, and one script handles each kind of motion for the whole site, so timing stays consistent. Interface motion is short and eases out: colour changes in 140 ms, movement in 220 ms. Effects load after the page is idle and switch off for reduced motion.
Timing and easing
| Token | Light | Dark |
|---|---|---|
--ease | cubic-bezier(0.22, 0.61, 0.36, 1) | same |
--motion-color | 140ms var(--ease) | same |
--motion-position | 220ms var(--ease) | same |
Designing progress, with confidence.
data-splitHeadline lines slide up out of a mask, one after another.

data-unmaskImages open from the left edge while settling from a slight zoom.
data-riseTiles and text fade up 18px with a short stagger.
data-ruleDividers draw from left to right.
data-ditherCanvas with a drifting 1-bit dither field and an accent light that follows the pointer.

data-shaderCover image with a cursor trail that warps the pixels along the direction of travel.