Design4 min read

A small design system with Tailwind

Start with shared tokens and a few well-defined components, then expand when the product gives you a reason.

In a growing interface, inconsistency usually arrives through small local decisions. One screen gets a tighter button, another gets a different muted text color, and eventually similar actions look unrelated.

I don't need a large component library to start fixing that. I need a few shared decisions and a way to keep using them.

Start with the values that repeat

I choose a type scale, a spacing rhythm, and semantic color roles before trying to model every component.

Tailwind provides scales, but it doesn't choose a hierarchy for the product. I still need to decide which size belongs to a page heading, how a field label relates to its input, and how much space separates sections.

Color roles help prevent the same problem across themes. A component should ask for the surface color or muted foreground, rather than choose a gray independently.

In Tailwind v4, custom properties need to be connected to the theme for utilities such as bg-background to exist:

@import "tailwindcss";

:root {
  --background: oklch(98% 0.005 250);
  --foreground: oklch(20% 0.01 250);
  --muted-foreground: oklch(45% 0.01 250);
}

.dark {
  --background: oklch(15% 0.005 250);
  --foreground: oklch(96% 0.005 250);
  --muted-foreground: oklch(72% 0.01 250);
}

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-muted-foreground: var(--muted-foreground);
}

Here, the .dark class on an ancestor changes the underlying values. The component can keep using bg-background text-foreground. This example defines only text and surface roles; a real interface will also need decisions about borders, focus, and status colors.

Extract behavior as well as appearance

Repeated markup is a clue, but it isn't the whole reason to make a component.

Two panels may look alike while serving different jobs. Sharing tokens can keep them visually related without forcing them into one API. Conversely, a field component may be worth extracting early because it keeps the label, help text, error, and accessibility relationships together.

I use established primitives for interactions such as menus and dialogs. Styling an accessible primitive is less work than rediscovering keyboard navigation and focus management for each project. It still needs testing after customization.

Keep variants finite

For a component with a few predictable visual options, I like class-variance-authority. It keeps the alternatives together and avoids assembling conditional classes at every call site.

This is a small button-style example using Tailwind's built-in color utilities:

import { cva } from "class-variance-authority";

export const buttonStyles = cva(
  [
    "inline-flex min-h-11 items-center justify-center rounded-md",
    "px-4 text-sm font-medium",
    "focus-visible:outline-2 focus-visible:outline-offset-2",
    "focus-visible:outline-blue-500",
    "disabled:pointer-events-none disabled:opacity-50",
  ],
  {
    variants: {
      intent: {
        primary: "bg-zinc-900 text-zinc-50 hover:bg-zinc-800",
        secondary: "bg-zinc-100 text-zinc-900 hover:bg-zinc-200",
      },
    },
    defaultVariants: { intent: "primary" },
  }
);

A button can use buttonStyles({ intent: "secondary" }) as its class name. In a themed product, I'd replace the literal surface colors with the shared roles.

This handles styling, not the entire button contract. The rendered control still needs the correct element, button type, accessible name, and disabled or pending behavior.

Check themes as complete screens

Changing variables makes dark mode easier to implement. It doesn't prove the result is readable.

I check text contrast, boundaries between surfaces, focus rings, disabled controls, and images in each theme. A logo that works on a light surface may disappear on a dark one. A status conveyed only by color remains a problem in either.

Screenshots are useful here because local consistency can hide a weak overall hierarchy. I want to see the component among the other things it needs to coexist with.

Expand when the use cases diverge

I don't add a variant just because I can imagine it. I wait for a real use case, then check whether it belongs to the same component.

There isn't a magic limit on the number of variants. The warning sign is that changing one combination breaks another, or that callers need to know too much about the component's internal layout.

A small system earns its place by making the next screen easier to build and keeping existing screens consistent. When an abstraction makes both harder, I simplify it.

Want to work together?

I help companies design and build products from the ground up. Let's talk about your project.

Get in touch