webdev.complete
🌬️ Tailwind CSS
🎨CSS Power
Lesson 23 of 117
30 min

Tailwind in Practice

Components, design tokens with @theme, dark mode, shadcn/ui.

Knowing every Tailwind utility doesn't make you good at Tailwind. The skill is recognizing the dozen or so patterns that come up in every real project: how to extract components, where to put design tokens, when to use arbitrary values, and how to wire up group/peer states without going mad. This lesson is the cookbook.

Pattern 1: extract a component, not a class

First reflex when you see the same five classes three times in a row: extract a component in your view layer. Not a CSS class. The classes live in the component, the call site is clean.

tsx
// components/Button.tsx
type Props = { variant?: "primary" | "secondary"; children: React.ReactNode };

export function Button({ variant = "primary", children }: Props) {
  const base = "px-4 py-2 rounded-md font-medium transition";
  const variants = {
    primary: "bg-indigo-600 text-white hover:bg-indigo-700",
    secondary: "bg-zinc-100 text-zinc-900 hover:bg-zinc-200",
  };
  return <button className={`${base} ${variants[variant]}`}>{children}</button>;
}

For complex variants, libraries like cva (class-variance- authority) and tailwind-variants formalize this pattern. Same idea, more ergonomic API.

Pattern 2: design tokens via @theme

Hard-coding bg-[#5B21B6] works once. The minute you need to use that color again, define it as a token. Tailwind v4 makes this dead simple:

app.css
@import "tailwindcss";

@theme {
  --color-brand: oklch(0.55 0.22 280);
  --color-brand-hover: oklch(0.48 0.22 280);
  --color-surface: oklch(0.98 0 0);
  --color-surface-dark: oklch(0.15 0 0);

  --font-display: "Inter Tight", system-ui;

  --radius-card: 0.75rem;
  --shadow-card: 0 2px 8px rgb(0 0 0 / 0.06);
}

Now bg-brand, hover:bg-brand-hover, font-display, rounded-card, and shadow-card all work in your markup. Designers can update the tokens, the whole site shifts.

Pattern 3: custom utilities with @utility

Sometimes you want a utility that isn't a simple property:value mapping. For example, a typographic ratio or a multi-property stack. Tailwind v4 gives you @utility:

app.css
@utility prose-narrow {
  max-width: 65ch;
  line-height: 1.6;
  & p { margin-bottom: 1em; }
  & h2 { margin-top: 1.5em; }
}

Apply with class="prose-narrow". You get a first-class utility that fits the rest of Tailwind's scale.

Pattern 4: arbitrary values for one-offs

Sometimes you genuinely need padding: 17pxfor some designer-driven exact pixel. Tailwind's arbitrary value syntax [...] handles it:

html
<!-- One-off padding -->
<div class="p-[17px]">...</div>

<!-- One-off color -->
<div class="bg-[#5B21B6]">...</div>

<!-- One-off CSS variable reference -->
<div class="text-[length:var(--big-size)]">...</div>
Arbitrary values are an escape hatch
If you find yourself using p-[17px]ten times across the codebase, that's a token. Promote it to @theme and use the named utility everywhere. Arbitrary values are for the truly one-off cases.

Pattern 5: arbitrary variants for deep targeting

Sometimes you need to style a descendant from a parent, or hook into a specific data attribute. The [&_selector]:utility syntax lets you write arbitrary selectors as variants:

html
<!-- Style direct child paragraphs -->
<div class="[&_p]:text-zinc-600">
  <p>Will be gray.</p>
  <p>Also gray.</p>
</div>

<!-- Target a data attribute -->
<div class="[&[data-state=open]]:rotate-180">
  Caret that flips when open
</div>

<!-- Custom pseudo-class chain -->
<button class="[&:is(:hover,:focus-visible)]:ring-2">Either hover OR focus-visible</button>

Pattern 6: group and peer

The two relational variants that unlock most of the "style child based on parent state" patterns:

  • group: mark a parent as a group. Children can use group-hover:, group-focus:, group-data-[...] to react to the parent.
  • peer: mark a sibling as a peer. Following elements can use peer-checked:, peer-invalid:, etc. Useful for unstyled input + custom label/checkmark pairs.
html
<!-- group: card hover changes child color -->
<a href="#" class="group block p-4 rounded-lg border hover:border-indigo-600">
  <h3 class="text-zinc-900 group-hover:text-indigo-600">Title</h3>
  <p class="text-zinc-500 group-hover:text-zinc-700">Body that also reacts</p>
</a>

<!-- peer: checkbox toggles its label's appearance -->
<label class="flex items-center gap-2">
  <input type="checkbox" class="peer sr-only" />
  <span class="w-5 h-5 rounded border peer-checked:bg-indigo-600 peer-checked:border-indigo-600"></span>
  <span class="peer-checked:line-through">Take out the trash</span>
</label>
Name your groups
For nested groups, use group/name and group-hover/name: to disambiguate. Same trick works with peer. Saves you from accidentally cascading hover styles across every nested card.

Pattern 7: responsive prefixes (and reading them backwards)

Tailwind's responsive prefixes are min-width: md:means "medium breakpoint and up." You layer them mobile-first:

html
<!-- 1 column phone, 2 columns tablet, 3 columns desktop -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  ...
</div>

Read left to right as: "the smallest first, then each breakpoint enhancement." Once you internalize that, very long responsive class strings stay readable.

Pattern 8: the shadcn/ui approach (copy, don't install)

The most influential UI library of the last two years isn't really a library: it's shadcn/ui. The idea is radical: you don't install components, you copy them into your codebase. A CLI grabs the source and dumps it into your project. From then on it's your code.

bash
npx shadcn@latest add button
# downloads button.tsx into /components/ui/ for you to own and modify

Why this matters for Tailwind: shadcn/ui components are pure Tailwind + Radix primitives. Once they're in your codebase, you can adjust them to your design tokens, your variants, your needs. No wrestling with a third-party API. No version-pinning a UI library.

  • You own the code; refactor it freely.
  • Updates are opt-in: re-run the CLI when you want them.
  • It works with your @theme tokens automatically.
  • Trade-off: you're responsible for maintaining them. Tradeoff most teams happily make for the control.

Pattern 9: container queries with Tailwind

Tailwind v4 ships container query utilities. Mark a parent as a container, then use @<size>: prefixes on children:

html
<div class="@container">
  <article class="grid @md:grid-cols-[120px_1fr] gap-3">
    <img src="..." class="w-full @md:w-30 rounded" />
    <div>
      <h3 class="text-lg">Title</h3>
      <p class="text-sm text-zinc-600">Body</p>
    </div>
  </article>
</div>

The card responds to its own width, not the viewport. Drop it anywhere on the page and it adapts.

Quiz

Quiz1 / 4

You see the same six Tailwind classes on a button across 10 files. What's the right fix?

Recap

  • Extract components, not classes. Reuse looks via your view layer.
  • Put design tokens in @theme. They become named utilities automatically.
  • Arbitrary values [...] are escape hatches. Promote repeated ones to tokens.
  • group and peer handle parent/sibling relationships without JS.
  • Arbitrary variants [&_selector] let you write any CSS selector as a Tailwind class.
  • shadcn/ui "distribution": copy components into your codebase, own them, customize freely.
  • Container query utilities (@md:, etc.) make components responsive without media queries.