Design System from Scratch
Summary
Codifying 60+ organic React components into a documented design system so ten engineers could ship on-brand without me. Built with Storybook 10, Tailwind 4, and Radix UI primitives.
index

Storybook sidebar showing Foundations / Display / Inputs groups, with KButton story rendering all 8 looks plus isPill, disabled, KButtonLink, and KButtonAsync variants The system in the browser — sidebar grouped by responsibility, KButton rendering every look + state, controls exposing the canonical size prop. Open the live Storybook →

Business Case

Same AI platform I rebuilt the setup wizard for in /work/web-scraping-platform. Different problem this time.

Ten backend engineers. One designer-frontend (me). Velocity wasn’t a backend problem — engineers know how to build. It was a design problem. Every new feature meant another spacing decision, another button variant, another check-in with me before something could ship. I was the bottleneck.

The scale unlock wasn’t more designers. It was codifying the design system so engineers could build on-brand without me. A documented contract — tokens, primitives, governance — turns “is this on-brand?” from a question I have to answer into a check engineers can run themselves. The system doesn’t lock design down to ship faster. It lets ten people ship from the same source of truth.

Sixty-plus React components had grown organically across two years. Spacing drifted. Naming drifted. Contracts drifted. New features kept rebuilding primitives because finding the existing ones was harder than copying them. There was no Storybook. No tokens. No documentation. Just a kui/ directory that worked, mostly, and a globals.css file holding the only real source of truth for color and type.

The ops team had Activity Log queued — filter agent runs by workflow, status, date range; preview payloads; export for compliance. Building it on the existing pile would have meant another round of copy-paste primitives. The cheaper move was to codify first, then build Activity Log as the system’s first real consumer.

Live Storybook: design-system-demo-rho.vercel.app — 52 stories, dark mode, a11y addon, Foundations/Tokens MDX page.

My Role

Sole architect and committer on the system itself — token extraction, Storybook setup, the audit pass, the Activity Log composition, and the later extension pattern. Two other frontend engineers shipped product features against the system once it stabilized; the governance contract was mine to hold.

  • Audit: catalog 60+ components by responsibility (general / display / interactive / inputs).
  • Atomize: extract the design contract from globals.css — typography scale, color ramps, breakpoints, motion canon.
  • Codify: Storybook 10 + @storybook/nextjs-vite, 52 stories, a Foundations/Tokens MDX page.
  • Consume: compose the Activity Log dashboard from existing primitives.
  • Extend: later — design and ship the Conditional Date Filters pattern as composition, not invention.

Part 1 — From scratch: codifying the atoms

The system as I found it

Sixty-plus components in kui/, each a working React module, none documented. Some exposed size as sm | md | lg. Others used small | medium | large. A KButton had eight look variants and six sizes; nothing said which combinations were sanctioned. A KBadge existed in three near-identical implementations across three feature folders.

The globals.css file held the real tokens — Tailwind 4 @theme blocks for color, typography, radius, shadow — but nothing referenced them by name in design conversation. Drift was the default.

Audit, atomize, codify

Three moves, in order.

Audit. I cataloged every component in kui/ by responsibility: general (KCard, KSurface), display (KBadge, KTable, KTimeline, KCode), interactive (KButton, KSelect, KModal), inputs (KInput, KDatePicker, KDateRangePicker). The grouping became app/data.js — a single catalog file the rest of the system reads from.

Before After
───────────────────────────── ───────────────────────────────────
kui/ Foundations
├── KAccordion.jsx ├── Grid
├── KBadge.jsx ├── Typography
├── KBarChart.jsx └── Tokens (MDX, sourced from globals.css)
├── KBrowserMockup.jsx Display
├── KButton/ ├── KAccordion · KBadge · KCard · KCarousel
│ ├── KButton.jsx ├── KCode · KColor · KDiff · KIcons
│ ├── KButtonAsync.jsx ├── KImagePreview · KJsonTree · KNote
│ ├── KButtonDanger.jsx ├── KSpinner · KSquares · KSteps
│ └── Link.jsx ├── KTimeline · KToC · KTypewriter
├── KCalendar.jsx └── KVideoPlayer · PingPong
├── KCard.jsx Interactive
├── KCardContent.jsx ├── KDialog · KMenu · KMovable
├── KCardFooter.jsx ├── KPopover · KScrollToTop
├── KCardHeader.jsx ├── KTable · KTabs · KToaster
├── KCardLink.jsx └── KTooltip
├── ... 90+ more files Inputs
└── (no docs, no Storybook, ├── KButton · KCheckbox · KCheckboxGroup
no token contract, ├── KCodeEditor · KColorPicker · KCopyButton
no grouping) ├── KDateInput · KDatePicker · KEditable
├── KEmail · KInput · KPassword
├── KRadioGroup · KSelect · KSlider
├── KTabularInput · KTextarea · KWords
└── (each: 1 story, size control, a11y panel)

113 files in kui/ collapsed into 4 sidebar groups + 52 named stories. The grouping isn’t decoration — it’s the contract a contributor reads before adding component #61.

Atomize. I extracted the design contract from globals.css into a named token map — --color-fg-primary, --space-2, --radius-md, --motion-fast — and audited every component for token references vs. magic numbers. The magic numbers got replaced. The 100ms motion canon (used everywhere from button hover to modal entrance) became a documented --motion-fast rather than a hardcoded transition-duration: 100ms repeated across forty-three files.

Codify. Storybook 10 with @storybook/nextjs-vite — the framework combination that integrates cleanly with Tailwind 4 + Next.js 16 (the alternative Webpack-based @storybook/nextjs requires extra build wiring on Tailwind 4). I imported Tailwind directly in .storybook/preview.css because Vite drops nested @imports in globals.css — a documented community gotcha. Stories were generated from the existing public/kui-snippets/*Demo.jsx files via a small script: 52 stories, one per component, with size as the canonical control. The Foundations/Tokens page is an MDX doc that reads tokens from the same source globals.css — a single edit propagates to docs, stories, and runtime simultaneously.

Foundations / Tokens MDX page in Storybook — Zinc, Orange (brand primary), Sky, Green color ramps with HSL values inline Foundations/Tokens MDX docs page — sourced live from globals.css. Zinc neutrals, Orange brand primary, Sky for info CTAs, Green for confirmations. Open the live page →

The system didn’t grow new components. It grew a contract over the existing ones.

First consumer: Activity Log

The Activity Log brief was concrete. The ops team needed a dashboard that tracked every action across a team — workflow runs, configuration changes, user actions, notifications. Filter by workflow, user, date range, or event type. Export to CSV for audit-ready reports.

I composed it from five existing primitives.

Activity Log in motion — filter, sort, drill into events. Built entirely by composing existing primitives.

Activity Log composition diagram — five primitive types composing the dashboard Five primitive types — KDateRangePicker, KSelect, KInput, KBadge, KTable — composing one Activity Log dashboard. No new components.

KTable for the event rows. KBadge for status (FINISHED, RUNNING, FAILED, CHANGED). KSelect ×4 for the filter bar (event type, interface, resource type, user). KDateRangePicker for the date range. KInput for the workflow ID search. The “View details” drawer reused KModal and the existing payload viewer.

Activity Log full dashboard — filter bar, event table, paginator Activity Log: filter bar, status badges, resource IDs, paginator. Every visible element is a primitive already in the system.

Activity Log column anatomy Column anatomy — Time, Event, Resource, User, Interface, Details. The grid has four hard columns + a freeform Details affordance per row.

Composition over creation. Not a slogan — a budget. Building Activity Log as five composed primitives instead of a bespoke dashboard cost roughly a third of the time the previous “build a new thing” approach would have, and it surfaced the system’s first real stress test under load.

What shipping AL revealed

Shipping the Activity Log surfaced gaps the audit hadn’t. KTimeline didn’t have a dense variant suitable for high-frequency events; I added one. KCode lacked syntax tokens for JSON payloads; the existing color ramp covered it once I named the tokens properly. The pagination control was duplicated in two places — different implementations of the same idea — and got consolidated.

Governance debt got logged for v2: a contribution doc, a deprecation policy for the old pagination, and a token-sync infrastructure question (Style Dictionary) parked for later. No token sync infra — yet. The tokens live in one file; that’s enough until two consumers need them in different runtimes.

The system gained a stress test and a list of next-priority gaps. That’s what a first consumer is for.

Part 2 — Extending: a new pattern earns its place

The brief

Three weeks after the system stabilized, the notifications feature needed a new semantic. Rules already supported “before date X” and “after date X” — both absolute. Customers monitoring contract expirations, deadline tracking, or rolling windows kept asking for the relative version: trigger the notification when data changes occurred in the last seven days.

KDatePicker is absolute. The new requirement is relative. No existing primitive composed into it cleanly. The system’s first real test: could it grow without bending?

The decision: composition over creation

A new pattern, not a new component. Three existing primitives wired through one controlled state object:

  • KSelect for the operator (before, after, in the last)
  • KInput for the quantity (1)
  • KSelect for the unit — minutes, hours, days, weeks, months

Conditional Date Filters anatomy — three primitives composing one pattern Three atoms — KSelect (operator) + KInput (quantity) + KSelect (unit) — composing the “in the last X” pattern. One new variant added to KSelect for the unit menu; no new component invented.

The only addition to the system was a new compact variant on KSelect for the unit menu — narrower, tighter padding, designed to sit flush against an inline numeric input. That variant landed as a single PR in the existing component, with a story added to KSelect.stories.tsx. The rest of the pattern is composition.

The state is one object: { operator: 'in_the_last' | 'before' | 'after', quantity?: number, unit?: 'days' | 'weeks' | 'months', date?: Date }. The component switches its inner layout on operator. Operator picker stays mounted. Mental model preserved: it’s still a date filter, just a relative one.

Anatomy and motion

Conditional Date Filter — operator dropdown showing "in the last" option The operator dropdown — the entry point that switches the row from absolute to relative.

Operator change → quantity input + unit picker fade in at the canonical 100ms. Same motion token as every other state change in the system.

Library parity in design tooling

The same atoms live in Penpot. Atomic → composed hierarchy mirrors kui/; the operator vocabulary the React component exposes is the same vocabulary a designer composes with through Swap Component. One contract, two surfaces.

Penpot library tree — KInput and KSelect atoms top-left, KSelect/dates and its relative and absolute variants in the middle, Condition/date and Rule and Ruleset cascading down the canvas Penpot library mirroring kui/. Atoms (KInput, KSelect) top-left; composed (Condition/date, Rule, Ruleset) bottom-right — the React component tree, in design-tool form.

Penpot editor showing a Ruleset of five rules with the Swap Component panel exposing relative and absolute variants Composing a Ruleset in Penpot — same operators, same units, same variants as the React contract. Swap Component (right panel) toggles relativeabsolute exactly the way the React component switches inner layout on operator state.

Live composition in Penpot — variants swap, operators change, rules drop in. Designers reach for the same atoms engineers do.

Adoption

Within a week, the pattern got adopted twice more — once by the Activity Log’s date filter (the same dashboard from Part 1, retrofitted), once by quota alerts. The system grew; the atoms didn’t move. The pattern earned a Storybook story so the next engineer doesn’t reinvent it.

That’s the test. The atoms hold. The pattern is documented. New surface area is composition, not invention.

What this proved about the system

A documented system with a governance contract grows by composition. A pile of components grows by reinvention. The difference isn’t visible on day one — it’s visible on day twenty-one, when someone asks for “in the last X” and the answer is three primitives plus one variant rather than a new component, a new contract, and a new round of drift.

That’s the start of a design system. The next layers — full a11y audit (focus order, ARIA on KModal/KSelect, contrast tokens beyond AA), Figma library parity with kui/, Style Dictionary token sync, contribution and deprecation policies — are documented in the v2 backlog. The MVP shipped because it had a real consumer driving it. The v2 work needs the same shape: a real consumer, a real test, a measurable gap.

Retrospective

  1. Code-first design systems reveal what design-tool-first systems hide. Atomizing from globals.css exposed the magic numbers, the duplicated pagination, the missing motion token. A Figma-first audit would have caught the visual drift; a code-first audit caught the contract drift, which is the more expensive kind.
  2. A system earns the right to grow by being used first. Activity Log was the real test of the codification work. Without a first consumer, the audit would have stayed an opinion. With one, the gaps surfaced and the next-priority list wrote itself.
  3. Composition is the cheapest path to “new” — and the most honest. The Conditional Date Filters pattern shipped in a fraction of the time a bespoke component would have, and it left the atoms intact. When a system is good, “new” should mostly mean “newly arranged.”

Public Storybook: design-system-demo-rho.vercel.app · Foundations/Tokens page: design-system-demo-rho.vercel.app/?path=/docs/foundations-tokens—docs · Related case study: /work/web-scraping-platform