Building a Design System From Scratch: Our Component Library Journey
When we started building AIDToolStack, our component library was a mess. Every developer created their own button variant, spacing was inconsistent across pages, and changing the color scheme meant touching 200 files. We needed a design system — not a massive enterprise system with 50 stakeholders, but a practical, developer-friendly system that one team could build and maintain.
Here is how we built it, what decisions we made, and what we would do differently.
Starting Point: Defining Constraints
Before writing any code, we established constraints:
Technology: Tailwind CSS for styling (already in the project), Radix UI for accessible primitives (headless components with keyboard navigation built in), TypeScript for type safety.
Theming: we wanted multiple themes (dark, light, editorial, neon) controlled by a single class on the html element. No runtime CSS-in-JS. No theme provider re-rendering the entire tree.
Scope: we needed maybe 25-30 components. Buttons, inputs, cards, modals, dropdowns, tabs, badges, and a few domain-specific components. Not a full Material UI clone.
Maintenance: one developer (me) maintaining it. It needed to be simple enough that a single person could keep it consistent and up to date.
The Architecture Decision: CSS Variables + Tailwind
The key architectural decision was using CSS custom properties (variables) as the bridge between themes and components. Each theme defines a set of semantic color variables:
--background, --foreground, --primary, --primary-foreground, --secondary, --muted, --border, --ring, --card, --destructive, and so on.
Tailwind is configured to use these variables: background: "hsl(var(--background))", foreground: "hsl(var(--foreground))". Components use Tailwind classes (bg-background, text-foreground, border-border) that resolve to the current theme's variables at runtime.
Switching themes means changing which CSS variables are defined. No JavaScript runs. No components re-render. The browser's CSS engine handles the transition instantly. We even add a transition on the html element so theme switches animate smoothly.
This approach has a massive advantage: Tailwind's JIT compiler generates utility classes at build time, and those classes work with any theme. You write bg-primary once and it looks correct in all four themes because the underlying variable changes per theme.
Component Design Principles
Composition Over Configuration
Our Button component does not have 15 props for every possible variant. It has: variant (default, secondary, destructive, outline, ghost, link), size (sm, default, lg), and standard HTML button props. That is it.
If you need a button with an icon and text, you compose it: put the Icon component and text inside the Button. If you need a loading button, wrap the children in a conditional that shows a spinner. The Button does not know about icons or loading states — it just renders its children with the right styling.
This keeps components simple, predictable, and easy to maintain. Every prop you add to a component is a prop you need to test, document, and maintain forever.
Accessibility by Default
Every interactive component uses Radix UI primitives under the hood. Our Dropdown is built on Radix DropdownMenu. Our Dialog is built on Radix Dialog. Our Tabs is built on Radix Tabs.
Radix handles keyboard navigation, focus management, ARIA attributes, and screen reader announcements. We add styling on top. This means every component is accessible by default — developers cannot accidentally build an inaccessible dropdown because the primitive handles the hard parts.
The investment in Radix paid for itself within a month. Before Radix, every custom component had accessibility bugs that took hours to fix. After Radix, accessibility issues essentially disappeared from our bug tracker.
Consistent Spacing and Sizing
We use Tailwind's default spacing scale consistently: 1 (4px), 2 (8px), 3 (12px), 4 (16px), 6 (24px), 8 (32px). Every component's internal padding and margin uses these values. No arbitrary values like p-[13px] or mt-[7px].
For component sizes, we define three tiers: sm (compact, for dense UIs), default (standard), lg (spacious, for landing pages). Every component that has a size prop uses the same three tiers with consistent dimensions.
This consistency means components compose well together. A sm Input next to a sm Button looks correct because they share the same height and padding proportions. You do not need to fiddle with alignment.
The Theming Engine
Our four themes serve different user preferences:
Dark Zinc: the default. Neutral grays, blue accent. Easy on the eyes for long coding sessions.
Clean Light: bright, professional. High contrast for daytime use.
Warm Editorial: cream backgrounds, serif-inspired. Magazine-like reading experience.
Midnight Neon: pure dark with vibrant accent colors. Energetic, playful.
Each theme is defined as a CSS class with variable overrides. The theme selector component adds the appropriate class to the document element. The user's preference is stored in localStorage and applied before hydration (to prevent flash of wrong theme).
The hardest part of multi-theme support was ensuring adequate contrast in all themes. A text color that works on a dark background might be unreadable on a light background. We use semantic tokens (--foreground on --background always has sufficient contrast) rather than absolute colors, so contrast is guaranteed by the token definitions.
Component Documentation
We do not use Storybook. For our team size (small) and component count (30), Storybook is overkill. The build time, configuration, and maintenance overhead are not justified.
Instead, we have a /docs page in the main application that renders every component with its variants. This is a real Next.js page that imports the actual components. If a component breaks, the docs page breaks — giving us free regression detection.
Each component has a TypeScript interface that serves as documentation. The props are self-documenting: variant?: "default" | "destructive" | "outline" tells you everything. For complex components, a JSDoc comment on the interface explains usage patterns.
Lessons Learned
Start with too few components, not too many. We initially built 40 components, half of which were never used. Now we add components only when we need them in two or more places. Unused components are deleted.
Avoid premature abstraction. Our first Card component had props for header, footer, media, actions, and badges. It was impossible to use for non-standard card layouts. We replaced it with a simple styled div (Card) and composable sub-components (CardHeader, CardTitle, CardContent, CardFooter). Much more flexible.
Test visual regressions, not component internals. We use Playwright to screenshot key pages in all four themes. If a component's appearance changes, we see it in the screenshot diff. This catches more real bugs than unit testing that "the button has class bg-primary."
Keep the design system close to the application. It lives in the same repository, uses the same tooling, and is maintained by the same developers who use it. Separate design system repositories create coordination overhead and version drift.
What We Would Do Differently
We would use CSS Layers (@layer) from the start. Tailwind 4 supports layers natively, and they solve specificity issues that caused us headaches with theme overrides.
We would define more semantic tokens earlier. Having --sidebar-background, --tool-card-border, and --code-block-background as separate tokens (rather than reusing --card and --border) gives more theming flexibility without touching component code.
We would establish a changelog from day one. When we changed a component's API (renamed a prop, changed default behavior), downstream code broke silently. A changelog with breaking changes would have made these transitions smoother.
The design system is never "done" — it evolves with the product. The key is keeping it small enough to maintain and flexible enough to handle new requirements without major rewrites.
Written by AIDToolStack Editorial
The AIDToolStack editorial team reviews developer tools, AI products, and MCP servers with hands-on testing and honest assessments. We use the tools we review every day in production.
References & Further Reading
Related Posts
Sponsor Our Newsletter
Reach thousands of developers who are actively evaluating AI tools, MCP servers, and dev infrastructure. Our weekly newsletter goes to engaged technical decision-makers.
All sponsored content is clearly labeled per our editorial policy.