Tailwind Component Patterns

The most common criticism of Tailwind is "long class lists in the markup". In practice, the answer isn't hiding them with @apply everywhere. It's the same answer as for any repetition in code: extract components. A <Button variant="primary" size="sm"> encapsulates its utilities once, and the rest of the app uses the component.

Mature Tailwind codebases share a toolkit: component extraction in your framework, typed variant APIs (class-variance-authority or tailwind-variants), a cn() helper combining clsx and tailwind-merge so consumers can override classes safely, automatic class sorting with Prettier, and headless UI libraries for accessible behavior. This is the approach popularized by shadcn/ui.

TL;DR

Quick Example

A button with typed variants, safe overrides, and headless composition (React):

Core Concepts

Extract Components, Not Classes

Tailwind's philosophy is that styling belongs with markup, and reuse happens at the component level. When the same utilities repeat:

  1. Within one file: loops, or a local variable holding the classes.
  2. Across the app: a component in your framework (Button, Card, Badge, FormField).
  3. For non-component templates (server-rendered partials, Markdown), a partial or macro.

This keeps styling co-located, lets you delete unused styles with the component, and avoids naming hundreds of CSS classes.

When @apply Makes Sense

@apply composes utilities into a CSS class:

Good uses: styling HTML you don't control (CMS or Markdown output, third-party widgets), and small base element styles. Overusing it recreates traditional CSS problems (naming, dead code, specificity) while losing co-location. In v4, files that use @apply with your theme outside the main stylesheet need @reference "../app.css";.

Variant APIs: cva and tailwind-variants

class-variance-authority (cva) and tailwind-variants define a base class plus typed variants, compoundVariants (styles for combinations such as variant=danger plus size=sm), and defaults. tailwind-variants adds slots for multi-part components (card header, body, and footer) and responsive variants. Both give components a clear, type-checked styling API.

Merging Classes Safely

Passing className="px-8" to a component that already has px-4 produces px-4 px-8, and which wins depends on stylesheet order, not class order. tailwind-merge understands Tailwind's utility groups and removes conflicting earlier classes, so the caller's px-8 wins. The cn() helper (twMerge(clsx(...))) is the standard pattern.

Headless Components

Accessible behavior (focus management, keyboard navigation, ARIA roles, dismiss on outside click) is hard to build correctly. Headless libraries provide it without styles:

Style them with Tailwind using the state they expose via data-* and aria-* attributes (see variants).

The shadcn/ui Model

shadcn/ui isn't a dependency: a CLI copies component source (built on Radix or other primitives, Tailwind, and cva) into your repo, so you own and customize it. The pattern (owned components, headless primitives, tokens as CSS variables, cn()) has become a common way to build Tailwind design systems quickly. See design systems.

Class Ordering and Tooling

Best Practices

Build a Small Set of Primitives

Buttons, inputs, cards, badges, dialogs, and layout primitives (Stack, Cluster, Grid) with variant APIs cover most UI. Pages compose primitives instead of repeating raw utilities.

Style With Semantic Tokens

Components should use semantic theme tokens (bg-surface, text-muted-foreground, ring-accent), not raw palette values, so theming and dark mode work without editing components. See theme configuration.

Keep Class Lists Readable

Group long lists with cva base arrays or line breaks, and let Prettier sort them. If a component's classes become unreadable, it's probably doing too much. Split it.

Document Components in Storybook

Showcase variants, states, and dark mode for each primitive, and test accessibility there. See Storybook.

Common Mistakes

@apply Everywhere

Converting every component into .btn { @apply … } classes brings back naming, specificity, and dead CSS, which defeats the point of utility-first CSS. Extract framework components instead.

Concatenating Classes Without Merging

Building Class Names Dynamically

` text-${size} ` isn't detected by Tailwind's scanner. Map variants to complete class strings (which is exactly what cva does).

FAQ

Is @apply bad practice in Tailwind?

Not inherently, but overusing it is. It's appropriate for styling markup you can't add classes to, and for small base styles. For reusable UI, extracting components in your framework is better: styles stay co-located, and component props provide variants.

What is tailwind-merge for?

It resolves conflicting Tailwind classes, keeping only the last one within the same utility group (for example the last padding class). That lets components accept className overrides that reliably win over their defaults.

Should I use cva or tailwind-variants?

Both provide typed variant APIs. cva is minimal and widely used (shadcn/ui uses it). tailwind-variants adds slots for multi-part components, responsive variants, and built-in merge integration. Pick one and use it consistently.

What is shadcn/ui?

A collection of accessible, Tailwind-styled components (mostly built on Radix primitives) that you copy into your project with a CLI rather than installing as a package. You own the code and customize it freely. It's a popular foundation for Tailwind design systems.

Related Topics

References