Tailwind CSS

Building a design system on top of Tailwind CSS

TW

Tailwind CSS has become the default styling approach across most of our active projects, but a client's growing product reached the point where copy-pasted utility strings were creating real inconsistency: three different shades of what was supposed to be the same brand blue, buttons with slightly different padding depending on which page they'd been copied from, and a growing sense among the team that nobody actually knew what the current source of truth for spacing was. We built a small component library on top of Tailwind rather than abandoning it, since the utility-first workflow itself wasn't the problem; the absence of any layer enforcing consistency on top of it was.

Why utilities alone weren't enough

Using Tailwind's config to define design tokens, spacing scale, colors, typography, and building components around those tokens kept the utility-first workflow while adding real consistency. The config file becomes the actual source of truth in a way a Figma file or a design doc never quite manages to stay, because the config is the thing the build actually reads; there's no way for a color to drift out of sync with itself the way it can when a token lives in a document that developers reference from memory.

The @apply directive is tempting for extracting repeated utility strings into a single class name, and we tried it seriously before backing away from it. Composing actual components in the framework layer, a Button.tsx or a Button.vue with variant props, aged better than @apply-heavy CSS files for a specific reason: @apply just moves the same utility soup into a stylesheet instead of a template, so you still end up with a growing pile of near-duplicate class definitions that nobody wants to consolidate, and you lose Tailwind's biggest ergonomic win, seeing exactly what a component looks like from its class list, without gaining much in return.

  • Using Tailwind's config to define design tokens, spacing scale, colors, typography, and building components around those tokens kept the utility-first workflow while adding real consistency.
  • The @apply directive is tempting for extracting repeated utility strings, but we found composing actual components in the framework layer aged better than @apply-heavy CSS files.

Building the component layer

We started with the highest-repetition components first: buttons, form inputs, and cards, since those were the components with the most visible drift across the product. Each component takes a small set of variant props, size, intent, and maps those props internally to the right combination of token-driven utility classes, so a developer using the component writes `<Button intent="primary" size="sm">` and never touches a raw utility class for that component again. That last part matters more than it sounds; once developers stop hand-writing utility strings for a component that's supposed to be standardized, drift stops being possible rather than just being discouraged.

Documentation mattered more than we initially budgeted for. A component library that isn't documented gets used inconsistently anyway, because two developers guessing at the intended usage of an undocumented prop will guess differently. We built a small internal Storybook instance cataloguing every component and its variants, which also turned out to be useful during design review, since a designer can point at a Storybook entry and say exactly which variant they mean instead of describing it in words that might get interpreted differently by whoever implements it.

Where the line still gets redrawn

Tailwind doesn't replace the need for a design system; it just changes where the system lives. Token-driven configuration plus a thin component layer has worked better for us than @apply-heavy stylesheets, but it does require actual governance, someone has to own the token list and say no to one-off additions, or the same drift problem just resurfaces one config value at a time. We assign that ownership explicitly now rather than leaving the config file as unowned shared property, which is closer to how we already treat anything else foundational enough to affect the whole product.

Handling components that don't fit the token model cleanly

Not every design element maps neatly onto a token, and we had to develop a rule for what happens when it doesn't. A one-off marketing page hero section with a bespoke gradient that will never be reused anywhere else doesn't belong in the shared token list, since adding it there would bloat the config with values nobody else will ever reference, but it also shouldn't be built with entirely arbitrary, unaudited utility values scattered through the markup. Our compromise was a small "local tokens" pattern: a scoped set of CSS custom properties defined at the top of a specific page or feature, referenced through Tailwind's arbitrary value syntax, which keeps one-off values contained and clearly signaled as intentionally one-off rather than either polluting the shared design system or reading as unreviewed inconsistency.

Keeping the library from drifting the way the raw utilities did

The irony we were watching for closely was the component library itself quietly becoming the next thing that drifts, exactly the failure mode we built it to prevent. A `Button` component that accumulates one-off prop combinations added under deadline pressure, `hideOnMobile`, `extraPadding`, `legacySize`, each addressing a single call site's specific need, can end up just as inconsistent as raw utility strings were, just hidden one layer further down inside the component's implementation instead of visible in the markup. We now require any new prop added to a shared component to go through the same lightweight review the original token list did: does this represent a real, recurring variation, or is it one call site's specific requirement that should be solved locally instead of by growing the shared component's API surface indefinitely.

Migrating existing pages without a big-bang rewrite

Rolling the component library out across an already-large existing codebase couldn't happen all at once without an unacceptable amount of frozen feature work, so we adopted a strangler-style approach: new pages and new features use the component library exclusively from day one, while existing pages get migrated opportunistically whenever a developer is already touching that area of the code for an unrelated reason. This is slower than a dedicated migration sprint would have been, but it meant the client's product kept shipping the entire time, and eighteen months in, the highest-traffic pages, the ones touched most often for feature work, were also the first ones fully migrated, which is roughly the priority order we'd have chosen deliberately anyway.

Dark mode surfaced gaps the original token set hadn't accounted for

Adding dark mode support partway through this project was the first real stress test of whether the token system was actually complete or just complete enough for the cases we'd thought of so far. Several colors that had been hardcoded as a specific hex value rather than referencing a semantic token, a border color here, a subtle background tint there, had no defined dark-mode equivalent because nobody had needed one yet, and those gaps only became visible once we actually tried to flip the theme. We used the process as a forcing function to finish auditing every remaining hardcoded color value in the codebase, converting each one to a semantic token with both light and dark values defined, which left the design system meaningfully more complete than it had been before dark mode was ever a requirement.

Onboarding new developers to the token-and-component model

New developers joining a project already using this system pick it up faster than we expected, largely because the rule is simple to state even if it took us a while to arrive at it: reach for an existing component and its documented variants first, reach for the token list second if no component fits, and only touch a raw utility value directly as a last resort for something genuinely one-off. We walk through that decision order explicitly in onboarding now, with a couple of real pull requests as worked examples, rather than expecting a new hire to infer the priority order purely from reading the codebase and guessing at the unwritten convention behind it.

The harder ongoing question is where the component library's boundary sits. Too permissive, and developers reach for raw utilities to route around the library for anything slightly unusual, which recreates the original inconsistency problem one exception at a time. Too rigid, and legitimate one-off layouts get forced through an awkward component API that wasn't designed for them, which slows delivery for no real consistency benefit since a genuinely unique layout was never going to repeat anyway. We've settled on a rough rule: anything that appears in three or more places gets promoted into the component library, and anything that doesn't stays as plain, ungoverned Tailwind utilities, which has kept the library from either growing unbounded or feeling too restrictive to actually use.

← Back to the journal

Have a project in mind?
Let’s talk.

Tell us where you are and where you want to go. We'll map the fastest route between the two.

Currently accepting new clients