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.
// 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:
@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:
@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:
<!-- 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>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:
<!-- 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 usegroup-hover:,group-focus:,group-data-[...]to react to the parent.peer: mark a sibling as a peer. Following elements can usepeer-checked:,peer-invalid:, etc. Useful for unstyled input + custom label/checkmark pairs.
<!-- 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>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:
<!-- 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.
npx shadcn@latest add button
# downloads button.tsx into /components/ui/ for you to own and modifyWhy 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
@themetokens 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:
<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
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. groupandpeerhandle 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.