Skip to content
SJ
All writing
9 min read

A Design System, Not Class Soup

The unreadable-markup complaint about Tailwind is nearly always a symptom of skipping the config. Tokens first, and the class lists get short.

Tailwind CSSFrontendDesign SystemsCSS

The standard criticism is that Tailwind produces markup nobody can read. The example is always something like flex items-center gap-[13px] rounded-[7px] bg-[#1a1d24] px-[18px] text-[13px], and it is a fair thing to object to. But look at what those classes are: arbitrary values, every one of them. That is not Tailwind, it is inline styles with a worse syntax, and it happens when a team adopts the tool and never opens the config.

The real complaint

Tailwind is a constraint system. Its value is that there are twelve spacing values rather than infinite ones, so two developers pick the same one and the interface looks intentional. Arbitrary values opt out of the constraint, which removes the entire benefit while keeping the verbose syntax.

A square-bracket value in a class name is a signal: the design system does not have a name for this, so either it should, or this element should use an existing value. Occasional exceptions are fine. A codebase full of them means the config was never filled in.

Tokens first, markup second

Before writing components, name the values. Spacing, type scale, colour, radius, shadow — the vocabulary of the interface:

theme: {
  extend: {
    spacing: {
      xs: "4px", base: "8px", sm: "12px",
      md: "24px", lg: "48px", xl: "80px",
      gutter: "24px",
    },
    fontSize: {
      "body-md":     ["16px", { lineHeight: "1.6" }],
      "body-lg":     ["18px", { lineHeight: "1.6" }],
      "headline-lg": ["32px", { lineHeight: "1.2", letterSpacing: "-0.01em" }],
      "label-caps":  ["12px", { lineHeight: "1", letterSpacing: "0.1em" }],
    },
  },
}

Now p-md means something and p-[23px] looks wrong to a reviewer. The class list gets shorter because each class carries more meaning, and — the part that matters most — changing the spacing scale is one edit rather than a search across the codebase.

A caution on the type scale: bundling line height and letter spacing into the font size token means one class sets three related properties that should always change together. Splitting them across three classes is how text ends up with a heading size and body line height.

Semantic colour names, not literal ones

The most consequential decision in the config. Naming a colour blue-500 describes the pigment; naming it primary describes the role. The difference shows up the day the brand colour changes to green, at which point bg-blue-500 is either wrong or a lie.

Role-based names extend naturally to pairs — a surface and the text that belongs on it, a container and its foreground — which is what makes theming tractable:

colors: {
  surface:            "#11131a",
  "surface-variant":  "#32353c",
  "on-surface":       "#e1e2eb",
  "on-surface-variant": "#c2c6d5",
  primary:            "#acc7ff",
  "on-primary":       "#002f68",
}

The pairing convention carries real information: on-primary is the only correct foreground for primary, and it was chosen for contrast. That is a legibility guarantee encoded in the names, which is far more reliable than hoping each developer checks a contrast ratio.

For theming, define the tokens as CSS variables and let Tailwind reference them. Switching a theme becomes swapping variable values on the root element — no duplicated dark: variant on every rule, and it works for user-selectable themes, not just light and dark.

Extract a component; do not reach for @apply

The moment the same class list appears three times, the instinct is @apply:

.btn-primary {
  @apply px-md py-sm rounded-full bg-primary text-on-primary hover:opacity-90;
}

This feels like cleanup and mostly is not. You have moved the styles back into a stylesheet, reintroduced the naming problem Tailwind avoids, broken the ability to see an element's styling where it is used, and created a class whose specificity interacts unpredictably with utilities applied alongside it. It is the worst of both models.

Extract a component instead. In a component-based codebase you already have the right abstraction:

export function Button({ variant = "primary", ...props }) {
  return <button className={cn(base, variants[variant])} {...props} />;
}

The styles stay utilities, the repetition is gone, and the component can also own its accessible attributes, focus behaviour and disabled state — which a CSS class cannot.

The narrow case where @apply is right: styling markup you do not control, such as HTML from a CMS or a rich-text field. There is no component to extract, so a base layer rule is the only option.

Put accessibility in the system, not in reviews

Utilities make it easy to forget the states that are not the default one, and the reliable fix is to encode them once in the component rather than relying on everyone remembering.

  • Focus. Every interactive element needs a visible focus indicator. Use the focus-visible variant so it appears for keyboard users without adding a ring on every mouse click. Removing the outline without replacing it makes the interface unusable by keyboard.
  • Contrast. Enforced by the token pairing above, verified once when the palette is defined rather than per component.
  • Reduced motion. One global rule honouring the preference costs three lines and covers every animation you will ever add.
  • Hit targets. Bake a minimum size into interactive components. An icon button that is 16px on a phone is a target nobody can hit reliably.

What Tailwind is genuinely bad at

  • Complex stateful selectors. Deeply conditional styling based on sibling and parent state is expressible through variants and becomes unreadable. Plain CSS is clearer, and mixing the two is fine.
  • Long, conditional class strings. Building class names from several booleans produces markup worse than the CSS it replaced. That is a signal to extract a variant-driven component.
  • Content you do not author. CMS HTML has no classes on it. You need a prose layer, which is real CSS.
  • Class detection. The scanner reads source files as text, so a class assembled at runtime from a variable produces no CSS. Write complete class names and select between them; do not build them by concatenation.

The short version

Fill in the config before writing components — an arbitrary value in square brackets means the system is missing a name. Name colours by role and pair each surface with its foreground so contrast is structural. Extract components rather than reaching for @apply, and keep focus, motion and hit targets inside those components. Then accept the places it does not fit and write CSS there.

Written by Saumya Jain

Full Stack Engineer working on headless commerce, NestJS microservices, and real-time systems. Currently open to remote work.