blog-pro / design system

Tokens, type, surfaces, components.

Version
0.1 · foundations
Derived from
Predictions UI kit
Namespace
--color-*, --text-*, --elevation-*
01

Color

Nine palettes × twelve OKLCH steps. The 12-step ramp is the spine of the whole system — every post gets its own.

Canvas & ink

Chrome only — no hue

Palettes

Hover any swatch to reveal its OKLCH value

Generator

Deterministic per (palette, step)
// LIGHTS and CHROMAS are fixed across all palettes.
const LIGHTS  = [.20 .28 .35 .42 .48 .54 .60 .66 .72 .78 .84 .90]
const CHROMAS = [.08 .12 .16 .18 .19 .18 .16 .14 .12 .10 .08 .06]

// Hue drifts around the palette's baseHue.
hueOffsets = [-HV -0.7HV -0.5HV -0.3HV -0.1HV 0 0.1HV 0.3HV 0.5HV 0.7HV 0.9HV HV]

// One-line contrast picker. No WCAG solver needed — the ramp was tuned.
text = L > 0.58 ? "#0a0a0a" : "#fff"
API
themeFor(postId){ bg, text, strokes, paletteKey, step }. Deterministic hash of the post id picks one of 108 themes, so a post keeps its color forever.
02

Typography

Plex trio — Serif for display, Sans for UI, Mono for meta. One family, three registers.

Display Plex Serif · 700 --text-4xl · -0.02em Post titles, hero

Attention is the scarcest resource of the decade.

Title Plex Serif · 700 --text-2xl Section headings

Why I stopped reading Hacker News and started keeping notes.

Lede Plex Serif · italic 400 --text-lg Subtitle, standfirst

A longer opening paragraph, set in italic serif so it reads as voiced, not structural.

Body Plex Sans · 400 --text-base · 1.65 Running copy, UI

This is body text. It sits at 16 / 26 with balanced wrapping, which keeps orphans in check without forcing a narrow measure. Run out a couple of sentences and the line-height still feels generous.

Small Plex Sans · 400 --text-sm Captions, footnotes

Auxiliary copy — captions, footnotes, the stuff that sits in the margin.

Mono Plex Mono · 400 --text-sm Metadata, URLs, code

2026-04-20 · 12 min · ben.chien/essays/attention

Eyebrow Plex Mono · 400 --text-xs · uppercase Kicker above title

Essay · Tools for thought

03

Space & Radii

Doubling scale, starts at 4 px. Radii line up with card (20) and toolbar (pill).

TokenValueUse
--space-14pxHairline gaps
--space-28pxChip padding, tight gaps
--space-312pxToolbar button gap
--space-416pxDefault paragraph gap
--space-524pxCard padding, section breaks
--space-632pxShell padding
--space-748pxHero breathing
--space-864pxBetween sections
--space-996pxPage-level
--space-10128pxHero top/bottom
TokenValueUse
--radius-sm4pxCode tag, small chip
--radius-md8pxList item, surface card
--radius-lg14pxRaised panel
--radius-xl20pxPost card (front/back)
--radius-pill999pxToolbar buttons
Note
Card radius scales with --enlarged-scale when zoomed. Keep that custom property — it's used at runtime, don't hard-code a second radius value in the enlarged state.
04

Elevation

Four-stage card ladder (rest → hover → drag → enlarged) plus list-item and chrome shadows.

elevation-1
Card · rest
drop 4/12 · soft 2/4 · inset 1/.5
elevation-2
Card · hover
drop 6/18 · soft 3/8 · inset 1/.6
elevation-3
Card · dragging
drop 8/24 · soft 4/12 · inset 1/.7
elevation-4
Card · enlarged
drop 8/32 · soft 4/16 · no inset
Why the inset disappears at stage 4
The 1-pixel white inset reads as a paper edge at rest size. Blown up 4×, that hairline looks like a bug — so stage 4 drops it and leans on the drop shadow alone.
05

Motion

Two easings, four durations. Every animation in the system maps to one of these.

TokenValueUsed by
--ease-standardcubic-bezier(.2 0 0 1)Default transitions — toolbar, list, hover
--ease-iriscubic-bezier(.4 0 .2 1)Card expansion, iris overlay
--dur-fast0.2sButton state, list hover
--dur-base0.4sQuote fade
--dur-card0.6sCard layout reflow (left/top/size/radius)
--dur-flip0.8sCard flip (rotateY 180)
--dur-iris0.8sIris expand to 300vmax
06

Components

Every interactive surface in the system, with a proposed API. Shape first, implementation second.

<PostCard />

Front face + quote back, 260×364, flips on click
Ben Chien
Attention is the scarcest resource of the decade.
Ben Chien
Attention is the scarcest resource of the decade.
"I stopped measuring my day in hours and started measuring it in uninterrupted blocks."
Read essay →
Yun-Ting Hsu
Notes on a slower practice.
interface PostCardProps {
  post:         Post                     // { id, author, title, quote, url }
  theme?:       Theme                    // override generator; default themeFor(post.id)
  state?:       'rest' | 'flipped' | 'enlarged' | 'dragging'
  onFlip?:      (id) => void
  onNavigate?:  (id, origin: DOMRect) => void   // origin fuels iris
  renderFront?: (ctx, theme) => void             // pattern render-prop
}

<Toolbar /> & <Pill />

Segmented pill group, one-active
interface PillProps {
  pressed?:  boolean                  // aria-pressed; was .active class
  onClick?:  () => void
  children:   ReactNode
}
interface ToolbarProps {
  value:      'shuffle' | 'name' | 'date' | 'list'
  onChange:   (v) => void
}

<ListItem />

List-view row · themed background from the same generator

Ben Chien

Attention is the scarcest resource of the decade.

"I stopped measuring my day in hours and started measuring it in uninterrupted blocks."

Yun-Ting Hsu

Notes on a slower practice.

"The best ideas don't arrive on schedule — they arrive when I stop trying to schedule them."

interface ListItemProps {
  post:        Post
  theme?:      Theme
  href:        string
}

<SurfaceDecorated />

Blob mesh + paper grain — the "mood" primitive
interface SurfaceDecoratedProps {
  variant?:   'mesh' | 'mesh-still' | 'paper' | 'none'
  intensity?: number                 // 0..1, opacity multiplier
  children:    ReactNode
}
/* Slots:
   - 4 blob layers, mix-blend:multiply, 25s float @keyframes
   - Paper grain = 2 repeating-linear-gradients at 0° / 90°
   - Guides (.vertical-guide at 10%/90%) are a separate primitive   */

<IrisTransition />

Card → article route animation · hover the square
hover · expands to 300vmax, then navigate at 800ms
function irisNavigate(url, origin: {x,y}, color: string) {
  // 1. stash color + origin in sessionStorage for destination
  // 2. set iris ::before background + transform-origin
  // 3. add .expanding → width/height → 300vmax
  // 4. setTimeout(() => location = url, 800)
  // 5. on pageshow / visibilitychange: reset overlay (bfcache)
}

<Hero />

Logo → separator → title → subtitle · 80% width
interface HeroProps {
  kicker?:      ReactNode        // eyebrow / logo slot
  title:         ReactNode        // Plex Serif 700, clamp(3rem, 6vw, 5.5rem)
  subtitle?:    ReactNode        // Plex Mono, muted
  separator?:   boolean          // default true; 1px rule below logo
}
07

Naming audit

The original kit mixed kebab-case CSS classes with camelCase JS ids. Here is the unified map.

CSS custom properties & classes

BeforeAfterRationale
--canvas-bg --color-canvas Prefix with category, not suffix. Matches Radix.
--text-primary --color-ink "text-*" is reserved for type scale.
--text-secondary --color-ink-muted Same.
--text-muted --color-ink-subtle muted > subtle to give room.
--guide-line --color-rule "rule" is the typographic term.
--diagonal-pattern--pattern-diagonal Group patterns together.
.top-toolbar .toolbar Position is not the component's name.
.simple-totalpackage.page Self-explanatory > WordPress leftover.
.card-face.card-front / .card-back.card__face--front / --backBEM, no double class chaining.
.read-prediction-link.card__cta Generic, reusable.
.list-item-author / -headline / -quote.list-item__author / __headline / __quoteBEM element separator.
.iris-overlay.active / .expandingdata-iris="idle | open | expanding"State via data-attr is easier to read in devtools.

JS identifiers & DOM ids

BeforeAfterRationale
#shuffleAll #sortByName #sortByDate #viewToggleToolbar value=<action>No global ids. Component-owned.
#listViewContainer<PostList />Container ids are a React anti-pattern.
#irisOverlay portal <IrisTransition />Mount once, via React portal.
data-design data-pattern "design" is too broad.
data-name / data-headlinelift to component propsDataset was a JS-to-JS smuggling channel.
window.PREDICTIONS_DATA / PREDICTIONS_QUOTESusePosts() · async loaderGlobals were WordPress-coupled.
hasPredixCardsRunn/a (component lifecycle)Re-run guard belongs to the component.
THEMES / COLOR_PALETTES / CUSTOM_COLORS (module-globals)buildPalette(key) · themeFor(id)Pure functions, no hidden state.
CARDS_PER_PILE (closure-let)usePileSize() hookDerive from viewport, not mutate.
currentView stringdiscriminated union{ mode: 'piles' } | { mode: 'sorted', by, dir } | { mode: 'list' }
08

vs. Radix / shadcn

Where this system departs from modern DS conventions — on purpose and not on purpose.

Break 1 · Token naming
Radix ships semantic scales: gray-1..12, blue-1..12, with step 9 as the "solid". Our generator produces 12 steps too — but we bake them into CSS as a single computed bg per card, not as 12 addressable tokens. fix

Proposal: also emit --palette-blue-1 … -12 at build time so non-card surfaces (buttons, tags) can reach into the ramp.
Break 2 · Elevation semantics
shadcn uses semantic shadow tokens (shadow-sm, -md, -lg, -xl) mapped to generic use. Our --elevation-1..4 are state tokens (rest / hover / drag / enlarged) — not sizes. intentional

Keep. But alias --shadow-sm--elevation-1 for anything outside the card so we don't re-export the shadow stack twice.
Break 3 · State via className
Radix state lives on data-state="open|closed|checked". Ours is class-based: .flipped .dragging .enlarged .expanding. fix

Proposal: migrate to data-card-state. Styles become [data-card-state="flipped"] — readable in devtools, composable with Radix primitives later.
Break 4 · Global DOM positioning
Cards are position: absolute appended to <body>. Pile Y is computed in JS. Radix/shadcn assume flow layout. intentional

Keep, but scope. Move the absolute layer to .pile-stage with position: relative. Embeds won't clobber host pages.
Break 5 · No dark-mode token split
shadcn ships .dark token overrides. We have none — the whole kit is light-mode only. fix

Proposal: invert canvas/ink; cards stay light since color is the identity. Blob mesh drops to mix-blend-mode: screen on dark.
Break 6 · Accessibility primitives
Pill toolbar uses .active, not aria-pressed. Card flip has no keyboard handler. Iris overlay blocks nothing but isn't aria-hidden. fix

Proposal: Toolbar = role="tablist", pills = role="tab" with aria-selected. Card = button with aria-expanded. Honour prefers-reduced-motion — collapse flip to cross-fade, iris to instant.
Keep · Text-wrap balance everywhere
The kit uses text-wrap: balance pervasively. Radix leaves this to consumers. Keep the default — it's cheap and carries the editorial feel. ok
Keep · OKLCH-first color math
Most DS's still ship HSL/RGB ramps. OKLCH gives perceptually even lightness steps, which is why the L > 0.58 contrast rule is reliable. ok
Next up
Once these are accepted, the same tokens drive the homepage (pile + list views) and the single-post reading view. Nothing else gets invented.