Skip to content

Utility Class Hygiene

A UI principle for coding agents. Also covers tailwind, tailwind css, utility classes, class list, class order, class sorting, and 8 more.

Show all 14 aliases

tailwind, tailwind css, utility classes, class list, class order, class sorting, apply directive, theme directive, token naming, className prop, style override not working, tailwind-merge, clsx, variants

A utility-first stylesheet has no bad day until the class lists get long. Then every element carries a paragraph of styling, the same colour arrives under three names, and a className someone passed in silently does nothing. None of that is the framework failing. It is what happens when utilities are written without a convention. This doc covers the conventions that keep a utility-first codebase readable at size: writing fewer classes, naming tokens by role, sorting classes mechanically, closing the component's styling surface, and staying out of @apply.

Two things have to be true before utilities are the right tool at all. There has to be a design system with real tokens, or every class list fills up with invented values (see ui/layout-spacing.md and ui/typography.md on off-scale values). And there has to be a component layer, or the same twelve classes get retyped on every instance and there is no single place to change them. Without both, a stylesheet with named classes will serve better.

Write the Shortest Class List That Produces the Result

Every class on an element is something the next reader has to hold in their head. Most long class lists are shorter than they look, because utilities have shorthands, CSS has defaults, and the framework already implies some values.

// Bad - three ways of saying less than it looks
<div className="pt-4 pb-4 flex flex-row justify-between border border-2 border-border">

// Good - same rendered result
<div className="py-4 flex justify-between border-2 border-border">

Three collapses are doing the work there. Paired axis utilities fold into one (pt-4 pb-4 into py-4, and the same for px, mx, my). Utilities that restate a CSS default can go entirely, since flex-row is the default flex-direction and flex-wrap: nowrap is the default wrap. And a sized utility implies the unsized one, so border-2 already sets a border and border alongside it is noise. Alpha is the fourth: write it into the colour utility as border-border/50 rather than reaching for a second class. Tailwind v4 removed the separate *-opacity-* utilities, so a class list still carrying them is v3 code that has not been migrated.

Knowing which CSS properties have which defaults is what makes this readable rather than a lookup exercise. When a class earns its place, keep it. The goal is not brevity for its own sake, it is that nothing in the list is a no-op.

Name Tokens for Their Role, Not Their Appearance

A token named for how it looks is a token that lies the moment the brand changes. bright-red stops being bright, blue-button turns green, and every consumer now reads a name that contradicts the value. Name the job instead: error, primary, muted. The name survives the redesign because the role does.

Group the tokens by namespace when you declare them, so colour, spacing, and breakpoints stay in their own blocks rather than interleaving:

@theme {
  --color-primary: oklch(45% 0.2 270);
  --color-secondary: oklch(40% 0.23 283);
  --color-error: oklch(54% 0.22 29);

  --breakpoint-sm: 40rem;
  --breakpoint-md: 48rem;
}

Each namespace maps to a family of utilities: --color-error produces bg-error, text-error, border-error, and the rest, and --breakpoint-md produces the md: variant. Spacing works differently and is worth knowing, because it is the one people get wrong. In Tailwind v4 the whole spacing scale is derived from a single --spacing base value multiplied by the number in the utility, so p-4 is calc(var(--spacing) * 4) and any multiple works without being declared. Adding named spacing tokens on top of that gives you a second, parallel scale, which is usually a way to smuggle in the off-scale values the base scale exists to prevent.

Design tools export appearance names. Translate on the way in rather than pasting them, or the design system's vocabulary becomes whatever the last export happened to call things.

Sort Classes With a Tool, Never By Hand

A consistent class order makes two elements comparable at a glance: layout first, then box model, then typography, then state. The order matters less than its consistency.

// Bad - same categories, no order
<div className="p-2 w-1/2 flex bg-black h-2 font-bold">

// Good - layout, size, surface, spacing, type
<div className="flex h-2 w-1/2 bg-black p-2 font-bold">

Sorting by hand is attention spent on something a formatter does perfectly, and it degrades the moment someone is in a hurry. Run the official Prettier plugin for Tailwind CSS and let the order be a property of the repo rather than a rule people remember. One caveat worth stating: sorted order is for reading. It has nothing to do with which class wins a conflict, which is the subject of the next section.

Close the Component's Styling Surface

Here is the trap. A component renders bg-white, a caller passes className="bg-black", both classes land on the element, and the button stays white. Nothing is broken and nothing warns you.

Precedence in a utility framework follows the order the classes appear in the generated stylesheet, not the order they appear in the attribute. Two classes that set the same property are a coin toss decided by the build, so passing an override in is not an override at all, it is a second opinion the stylesheet may ignore.

The fix is not to make overrides work harder. It is to stop styling components from the outside. Expose the variations the component actually supports:

const BUTTON_VARIANTS = {
  primary: "bg-primary text-primary-foreground hover:bg-primary/90",
  secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/90",
  danger: "bg-error text-background hover:bg-error/90",
} as const;

// Omitting className from the props type is what closes the surface. Applying
// the variant after the spread is the runtime backstop for untyped callers.
type ButtonProps = Omit<ComponentProps<"button">, "className"> & {
  variant?: keyof typeof BUTTON_VARIANTS;
};

export function Button({ variant = "primary", ...props }: ButtonProps) {
  return <button {...props} className={BUTTON_VARIANTS[variant]} />;
}
// Bad - arbitrary utilities from outside, silently unreliable
<Button className="bg-black" />

// Good - a variation the component knows about
<Button variant="secondary" />

Variants buy more than correctness. They keep the same component looking the same everywhere, and they put every change to a variation in one place instead of scattered across call sites. The restriction is the point: a component that accepts any utility has no consistent appearance to protect.

Some components do have to accept classes from a caller, typically layout utilities on a wrapper: where the element sits, how wide it is, what it does at a breakpoint. Those go through a conflict-aware helper (cn, built on clsx and tailwind-merge) so the incoming class removes the one it replaces rather than racing it. Note the limit: an unconfigured merge only knows the framework's own utility groups, so a custom utility of your own is passed through untouched and can still collide. Register those groups with the merge, or keep custom utilities out of the classes a caller may replace.

export function Panel({ className, ...props }) {
  // Order inside the merge is the contract. The caller's classes come after
  // the layout defaults, so they win there, and before the surface classes,
  // so a caller's bg or border is dropped by the component's own.
  return <div {...props} className={cn("p-6", className, "rounded-xl bg-card")} />;
}

The caller gets the last word on position and spacing, where they know the context and the component does not. They get no word at all on colour, border, radius, and shadow, because those sit after the merged classes. That is the whole distinction: a component can accept help with where it goes without accepting opinions about what it looks like.

Skip @apply

@apply looks like it tidies a long class list. What it actually does is move the styling back into a named class, which reintroduces every problem the utility layer removed: you are naming things again, and in a global stylesheet the resulting class is reachable from anywhere, so changing it can regress a surface you were not looking at. It grows the CSS bundle too, since those declarations get emitted for each class rather than shared across every element that uses the utility.

In Tailwind v4 there is a further cost. A stylesheet processed separately from your main CSS entry, such as a CSS module or a <style> block in Vue, Svelte, or Astro, cannot see theme variables, custom utilities, or custom variants. Using @apply there requires pulling in the main stylesheet with @reference first, which supplies that context without emitting the referenced stylesheet into your bundle:

/* Button.module.css */
@reference "../app.css";

.button {
  @apply bg-primary text-primary-foreground;
}

The simpler answer in those files is to read the CSS variables directly. The theme is already exposed as custom properties, so background: var(--color-primary) needs no reference import, no extra processing, and no second naming layer.

/* Good - the token, used directly */
.button {
  background: var(--color-primary);
  color: var(--color-primary-foreground);
}
import styles from "./Button.module.css";

<button className={styles.button}>Save</button>;

Checklist

  • No class on the element is a no-op: no restated CSS defaults, no unsized utility next to its sized form, no paired axis utilities that fold into one.
  • Every token is named for its role, declared under the right namespace, and free of appearance words.
  • Class order comes from the formatter, not from a person.
  • Components expose variants. Callers do not pass raw utilities that overlap the component's own styling.
  • Any class a component does accept from outside goes through a conflict-aware merge.
  • No @apply. Scoped stylesheets read theme variables directly.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-ui-principle({ topic: "utility-classes" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems