Cello design system

Built from one file

Every value on this page is read from src/tokens/tokens.json when the site builds, the same file its CSS is generated from. Two themes, Day and Dusk. Text only ever sits on a surface. Green means action, earth means ground.

Day and Dusk

Both themes, side by side. The site follows your system setting until you pick one.

Day

A window at daybreak

Body text sits on the canvas at full strength.

Metadata and captions use the muted role.

Primary action A link → Tag

Dusk

A window at daybreak

Body text sits on the canvas at full strength.

Metadata and captions use the muted role.

Primary action A link → Tag

Color roles

Components use roles, never raw colors. Each one carries the line that says when to use it.

RoleDayDuskUse when
--bg-canvas The page. Never inside a component.
--bg-raised Panels and card bodies at rest. Don't stack two.
--bg-muted Hover fields, code blocks, table stripes. Not for selected or current.
--bg-inverse The rare inverted block. Text on it is --text-inverse.
--bg-plate An opaque panel carrying text over a scene or image (A1). Never translucent.
--text-primary All reading text.
--text-muted Metadata, dates, captions, ledes. Never fainter than this for text.
--text-inverse Text on --bg-inverse or on --action-primary.
--text-on-art Text over scene art (A3, rewrite only): always with the copy scrim or the focus dim under it, and each line passes 4.5:1 (3:1 large) against the 99th-percentile brightest pixel under it. Same in Day and Dusk: the scene, not the theme, is the surface.
--text-on-art-muted Labels over art or on the bar (A3): the bar's reading context. Same contrast rule as --text-on-art.
--text-accent Section titles, eyebrows, the hero name. Never the only cue for a link.
--action-primary Buttons, links, current nav item, scroll bar, selection. Green means action in both themes: signal-500 at Dusk, signal-700 at Day (4.70:1 on paper-100).
--focus-ring Keyboard focus on everything: 3px, offset 3px.
--accent-ground Grounding shapes: triangles, sills, decorative bars. Earth in both themes. Never text or action.
--accent-construction Construction lines and annotations only. Never text or action.
--border-strong Specimen frames, buttons, tags.
--border-muted Dividers and rules only, never a control's edge.
--scrim Behind text on images; behind the menu.

Primitives

The raw ramps the roles are built from. Components never use these directly.

--ink-950 #001a11 Midnight Ink. The Dusk canvas, Day text. Cello family neutral.
--paper-100 #f3ede2 Paper 100. The Day canvas. Cello family neutral.
--paper-200 #e0e0e0 Starlight. Dusk text.
--earth-400 #a89762 Earth. Grounding at Dusk.
--earth-500 #887850
--earth-600 #6f613e Deep earth. Grounding in Day.
--signal-500 #00bc44 The green sun. Action in Dusk.
--signal-700 #007a33 Action in Day. 4.70:1 on Paper 100.
--blueprint-500 #42a5ff Construction lines and annotations.

Seasonal inks

Art only: sky, far ridge, snow, forest and ground, pulled from Pacific Northwest photos and flattened like a WPA print. Never text, never action. At Dusk each ink mixes 60% toward Midnight Ink.

June · Meadow

July · Coast fog

August · Dry gold

September · High light

October · Storm sky

Type

National Park

Mt Hood at daybreak

Page and section titles, the hero name. --font-title

Geist Mono

Labels, nav, data and code

Everything else. The site's voice. --font-mono

Geist Sans

Long reading, at about 69 characters a line.

Case-study and blog body text only. --font-read

Switchback sets place names. It arrives with the landscape, where places do.

--type-display-hero The hero name only.

Cello

--type-page-title h1 of every page.

Design system

--type-section-title h2 sections.

Color roles

--type-heading In-article h2, large card titles.

One file, every value

--type-title h3, card titles.

Card and h3 titles

--type-body-lg Homepage and listing ledes.

Ledes on listings and the homepage.

--type-body Articles, cards, resume.

Cards, the resume and every paragraph outside an article.

--type-body with --font-read Articles, cards, resume.

Inside an article, the same size reads in Geist Sans.

--type-ui Buttons, nav, back links.

Buttons and back links

--type-small Card descriptions, captions, meta.

Captions, card descriptions and metadata.

--type-label Eyebrows, tags, badges, micro-labels.

Eyebrows · tags · badges

Space

Rem, named by pixels. Spacing comes from this scale or a layout token.

  • --space-4
  • --space-8
  • --space-12
  • --space-16
  • --space-20
  • --space-24
  • --space-32
  • --space-40
  • --space-48
  • --space-64
  • --space-96

Layout and stroke

--layout-container 1100px
The page column.
--layout-container-wide 1500px
Breakout carousels.
--layout-gutter clamp(1rem, 4vw, 2rem)
Page side padding; only the page container uses it.
--layout-measure 68ch
Reading width for prose.
--layout-measure-read 38em
Reading width for Geist Sans prose (--font-read): about 85 characters per line at body size (608px), widened from 31em on 2026-10-22 because the column felt cramped beside the 1100px media. Set in em, not ch: Geist averages 0.45em per character but its 0 is 0.66em, so ch would overshoot.
--layout-measure-narrow 46ch
Ledes and card copy.
  • --stroke-rule Dividers.
  • --stroke-control Buttons, tags, media.
  • --stroke-frame Cards, specimens.
  • --stroke-bar Heavy bars, mullions.

Motion

Parts arrive, lock in and settle, on one curve: cubic-bezier(0.16, 1, 0.3, 1). With reduced motion, everything finishes at once and no delay is kept, so content never waits.

--ease-out cubic-bezier(0.16, 1, 0.3, 1)
The house curve (Cello family layer). Arrivals, reveals, hovers.
--motion-ui 300ms
Hover, color, press, menu.
--motion-entry 600ms
Reveal, slide, wipe, scale.
--motion-stagger 60ms
Per item in a staggered entry.
--motion-stagger-dense 30ms
Per item in a long staggered list (more than about ten items, e.g. skill chips).
--motion-page-wipe 450ms
Signature: the page-to-page wipe (view transitions).
--motion-logo-loop 12s
Signature: the logo's assemble-and-break loop.
--motion-logo-break 700ms
Signature: the logo breaking apart on hover.
--motion-arrival-stagger 120ms
Signature (the landscape scenes, A3): the arrival's stagger, sky → far → mid → near (the far layer leads by 20ms more). Text never waits for it.
--motion-arrival-overshoot 0.107
Signature (the landscape scenes, A3): the nearest layer's spring overshoot on arrival (≈11%, damping 0.58); farther layers overshoot less. Half the drop on phones.
--motion-focus 550ms
Signature (the landscape scenes, A3): exploring ⇄ reading (≈95% of the focus recipe at this time).
--motion-scene-fade 700ms
Signature (the landscape scenes, A3): the ink atmosphere and the still's focus filter cross-fading between attention states.
--motion-handoff 400ms
Signature (the landscape scenes, A3): still ⇄ WebGL hand-off, and the place label and the bar's reading context coming and going.
--motion-none 0s
No wait. The reduced-motion contract zeroes every delay with it, so content never waits on motion.
--motion-reading-active 24s
Landscape reading drift: one slow movement phrase behind the work; no text or camera movement. Disabled by reduced motion, Save-Data and hidden tabs.
--motion-reading-rest 60s
Landscape reading drift: time at rest between movement phrases, so background rendering can sleep.
--motion-reading-strength 0.2
Landscape reading drift: maximum fraction of each cut plane’s inspected motion cap. Keep all motion inside the scene manifest bounds.

How a change gets in

  1. Edit tokens.json first: the value, the theme pair or the use-when line.
  2. Run pnpm tokens. It writes the CSS this page and the site read.
  3. Build. pnpm build fails on a new raw color, size, space or duration, a hand-built keyframe, font or transition, or a token file that's out of date.
  4. Prove it. pnpm ui:shots compares every page pixel for pixel; pnpm motion:table lists every animation for a text diff.