Case study

Atlas

A design system built from one token file: two layers of tokens, thirteen components, and a build that refuses to ship drift. The travel-ops dashboard is what it makes.

Role
Design system, component library, product design
Timeline
2026
Stack
React 19, TypeScript, Tailwind v4, shadcn/ui, Storybook 8
Links
Live · Storybook · GitHub
The Atlas dashboard in dark mode: an in-transit Tokyo trip on the ops board, four KPI tiles, a filterable trip list, and a 64% travel-budget ring
The dashboard, assembled from the library. Every color, radius and type size on it comes from one JSON file.
01 · Problem

One screen is easy. The tenth is the test.

Atlas is for frequent travelers and the coordinators who manage their trips: three to six trips a year, often overlapping, with the details spread across airline apps, inboxes and spreadsheets. They need one screen that shows what's moving, what's at risk, and how much of the budget is gone.

Building that screen once is the easy part. The real goal was a system that keeps the next ten screens consistent without me in the room: a named role for every color, a component for every repeated piece, and checks that catch drift before a reviewer has to.

02 · Tokens

One file, two layers, enforced.

The direction is a calm ops console: a deep teal-black canvas, a seafoam accent for what's moving, coral kept for what's wrong, and numbers set in mono so they read like instrument readouts. All of it lives in one file, tokens.json, in two layers. Primitives are the raw palette, 23 colors named by hue and lightness (teal-300). Semantic roles name a job (primary, card, foreground-muted): 18 of them, each pointing at a primitive, with a dark and a light value. Components only ever use roles.

The Storybook Semantics page: 'How a name travels' shows --teal-300 pointing to --primary, which becomes bg-primary, text-primary and border-primary, above a table of surface roles like --background and --card, each with its dark and light primitive and hex value
How a name travels: a primitive gets a role, and the role becomes the class a component uses.
  • A generator that says no.One zero-dependency script turns the JSON into the CSS for the app and Storybook. Before writing anything, it rejects a primitive that isn't a hex color, a role that skips the palette or points at a key that doesn't exist, a role missing its light value, a type style with an unknown font, and spacing off the 4px scale.
  • The build is the reviewer.The generated files are committed, and npm run build starts by checking them. If one is stale or was edited by hand, the build fails, so drift never reaches production.
  • Storybook speaks the same tokens.The generator also writes the tokens as TypeScript, because Storybook's own interface can't read CSS variables. Its sidebar, its accent and its docs pages are themed from the same file as the app.
  • Accessibility is decided in the tokens.Contrast is checked where values are defined, not after a screen is built. Status tints are capped so badge text stays at AA in both themes (light mode: primary 5.4:1, warning 5.8:1, info 5.0:1), so every component that uses them inherits it.
Atlas in dark mode
Dark: the default. Text stays neutral; coral is the only warm color, so a delay is the first thing you see.
The same Atlas dashboard in light mode
Light: same components, untouched. Only the roles' values change.
03 · Components

Thirteen components, two tiers.

The library has two tiers. Seven UI primitives (Button, Card, Badge, Input, Tabs, Progress, Skeleton) start from shadcn/ui and are restyled through the semantic roles; nothing in them knows about travel. Six travel components (TripCard, MetricCard, BudgetRing, IconContainer, EmptyState, PanelError) are built from those. The dashboard's panels only arrange components, so the page itself holds no styling decisions of its own.

Storybook showing the TripCard component's All Statuses story: trip cards for Reykjavík marked Upcoming in blue with a calendar icon, Tokyo In-Transit in seafoam with a plane icon, Vancouver Delayed in coral with a warning icon, and Singapore Completed, beside the sidebar listing TripCard's Docs, Default, Loading and All Statuses stories
One prop, four statuses: each status maps to a color role, an icon and a label in one place.
  • Status is a prop, not a style.A TripCard takes status and looks up its color role, tint, icon and label from one table. Status is never color alone: in-transit is a plane, delayed a warning triangle, upcoming a calendar, so the feed reads in grayscale and for colorblind users.
  • No raw colors anywhere.Not one hex value or raw palette class appears in the components or panels. A primary button is bg-primary, a card is bg-card rounded-card, which is what lets the light theme work without touching component code.
  • Props in, nothing fetched.Components are presentational. All data lives in one hooks file, so swapping the mock data for a real API touches no component.
  • States belong to the component.Each data component draws its own loading skeleton, and each panel owns its empty and error states behind its own error boundary. If the trip feed fails, the ops board and budget keep working.
Trip card, default: Tokyo, in transit, 80% of budget used
Default
Trip card loading skeleton
Loading
Trip feed empty state with a call to action
Empty
Trip feed error: Couldn't load your trips, with a Retry button
Error + retry
04 · Layout

Placed by what needs attention.

Each panel answers one question. Whatever needs action gets the most space and the top-left spot; slower-moving numbers sit at the edges.

Atlas layout wireframe A wireframe of the dashboard: (1) the ops board at top left, (2) four KPI tiles in a 2 by 2 block at top right, (3) the trip feed with status tabs and search below, and (4) the budget ring in the right rail. 1 2 3 4
  1. 1 What's happening right now?
  2. 2 How's the year going?
  3. 3 Show me one trip.
  4. 4 Am I over budget?
The layout as a wireframe, numbered from most to least urgent.
  • 1 · Ops boardThe trip in motion gets the most space. Delays and the next departure sit right under it, so nothing that needs action is off-screen.
  • 2 · KPI tilesFour year-to-date numbers in a 2×2 block beside the ops board, so the top of the page reads as one status check before any scrolling.
  • 3 · Trip feedEvery trip as a card, with status tabs and search for when a coordinator is tracking several travelers at once.
  • 4 · Budget railAnnual budget as a ring in the side rail. It changes slowly, so it gets less space than live status.
05 · Documentation

Written for whoever builds the next screen.

Storybook is the system's manual, not a component gallery. It opens with the foundations, then the rules, then every component in every state, so someone new can build a screen without asking which gray to use.

The Storybook Components overview page, explaining that components use tokens through Tailwind utilities like bg-primary and bg-card, and never primitives or hex values like --teal-300 or #3ddfd4, beside a sidebar of Foundation and Components sections
The rules come before the components: roles through Tailwind utilities, never primitives or hex.
  • Foundations first.Primitives and Semantics pages are generated from the same token file, so they can't go stale. Each role shows its dark and light value side by side, and the primitive it points at.
  • Every state is a story.38 stories cover the 13 components: defaults, variants, loading, empty and error. Six also have an edge-case story with long names or extreme values. One earned its keep: a long trip name pushed TripCard past its column. The fix went into the component, so every screen that uses it got it at once.
  • Recipes show composition.A StatRow recipe shows a row of MetricCards working together the way the dashboard uses them, so the step from component to screen is documented too.
06 · How I checked it

What the checks changed.

Before calling it done, I reviewed the markup for assistive tech and tested the layout on a phone-sized screen. Both checks found something.

07 · Outcome

What I'd do next.

A token change is now one edit to one file, and a single commit carries it to the app, the docs and both themes. The build won't let the generated CSS fall behind, and the docs can't, because they're generated too.

← All work Open Atlas