🧱

Component Library Day: 17 Components From Scratch

Feb 25, 2026 8 min read

Bare Next.js skeleton to a near-complete component library in a single day. 17 components across three phases, all hand-typed to internalize the patterns. Here’s how it came together.


Morning: API smoke tests + design system overhaul

Started the day by writing 17 integration tests against the FastAPI backend. All 14 endpoints covered. Two bugs surfaced immediately: httpx 0.28 broke Starlette’s TestClient (downgraded to 0.27), and CLIP choked on a 1x1 PNG test image (swapped to a 4x4 PIL-generated one). Quick fixes—17/17 green.

Then gutted the frontend’s color system. Swapped Playfair Display for Cormorant Garamond, replaced the entire vintage.* Tailwind palette with flat tokens matching the Figma handoff:

Updated all four UI primitives—Button, Card, Badge, Input—to use the new tokens. Clean build.


Afternoon: search + bridge components

Search components

Three components, each with a distinct interaction pattern:

Bridge primitives

Then knocked out 7 of 10 bridge primitives:

Each one is a small, focused server component. No "use client" except where hooks are needed (SearchBar, ImageUpload). The payoff comes next: BridgeCardFull composes all 7 primitives into a single card.


Patterns that clicked

Runtime colors need inline styles

Tailwind generates classes at build time, so bg-${color} doesn’t work when the color comes from a JS object. Platform badge colors and score circle borders use style={{ color: platformColor }} instead. Straightforward once you remember Tailwind’s static extraction model.

Union types with type guards

ProductCard accepts both search results and product summaries. A simple “score” in p discriminator lets TypeScript narrow the type at runtime without any casting:

type ProductCardProps = SearchResult | ProductSummary;

function isSearchResult(p: ProductCardProps): p is SearchResult {
  return "score" in p;
}

One component, two shapes, zero as assertions.

The .map() with early return pattern

ScoreBreakdown maps over three bars but returns null for image similarity when it’s null (not every bridge has image data). Took a minute to grok the nested returns—the outer function returns JSX, the .map() callback returns JSX per item, and either level can bail early. Once that layering was clear, the pattern felt natural.


What’s next

The component library is almost done. Pages are where it all comes together.

Vintage Vestige — a fashion intelligence platform connecting garments across 500 years of design history. Built with Next.js, TypeScript, Tailwind CSS, and React.