If you have ever opened a year old CSS file and tried to figure out which class does what, you remember the feeling. card-2, card-2-blue, card-2-blue-mobile, card-2-blue-mobile-final, all in different files, all targeting the same component, all "almost" doing the same thing. Renaming any of them is risky. Deleting any of them is scarier. So nobody does, and the file grows forever.
That graveyard of dead classes is what Tailwind was invented to prevent.
What is TailwindCSS, really
Think of Tailwind as a tool belt of pre cut Lego bricks for styling. Instead of inventing class names like card-header-title-medium-blue and writing CSS for them, you snap together a handful of small, single purpose classes right in your HTML:
<div class="rounded-lg bg-white p-6 shadow">
<h2 class="text-xl font-semibold text-slate-900">Mochi</h2>
<p class="mt-2 text-slate-600">A small white cat with big plans.</p>
</div>
Each class does exactly one thing. rounded-lg is just border-radius: 0.5rem. p-6 is just padding: 1.5rem. There is no naming meeting. There is no dead CSS. The styles live next to the markup that uses them. When you delete the markup, the styles go with it.
Two ideas drive the whole thing:
- Utility first. You compose your design from many tiny utilities, not from a few "components" that grow attribute soup over time.
-
Constrained, not free. The utilities only exist for the values you defined as design tokens (spacing scale, color palette, font sizes). You cannot accidentally write
padding: 13.7px. You can only pick fromp-1,p-2,p-3, etc. The constraint is the point.
That is the whole vibe.
Let's pretend we are building one
We want a way to write CSS that scales to a team and a year, without inventing names, without dead code, without a fight in code review every Monday. We will call it Tailwind.
For the running example, we are styling a small profile card with a name, an avatar, a bio, and two buttons. We will grow it piece by piece.
Decision 1: Ship a sensible default scale
We need a small, consistent set of values for spacing, type, and color. If everything is on the scale, components will feel like they were designed by the same person, even if 12 different people wrote them.
The defaults that ship with Tailwind:
-
Spacing: a
0.25rem(4px) base.p-1= 4px,p-2= 8px,p-4= 16px,p-8= 32px. Same scale used by margin, gap, width, height. -
Type:
text-xs,text-sm,text-base,text-lg,text-xl,text-2xl, all the way up totext-9xl. Each comes with a sensible line height. -
Colors: 22 named color families (
slate,gray,red,green,blue,indigo, ...) with 11 shades each (50to950). Plus full opacity control (text-blue-500/50, half opacity). -
Radii, shadows, borders: also on a scale.
rounded,rounded-md,rounded-lg,rounded-2xl.shadow-sm,shadow,shadow-md,shadow-lg.
The full classes look like this in a real component:
<button class="
inline-flex items-center gap-2
rounded-md
bg-blue-600 px-4 py-2
text-sm font-medium text-white
hover:bg-blue-500
focus-visible:outline-2 focus-visible:outline-blue-600 focus-visible:outline-offset-2
disabled:opacity-50 disabled:cursor-not-allowed
">
Adopt
</button>
Read it like a sentence, left to right. Layout, then box, then color, then text, then states. Once your eye adjusts (give it a week), it is faster to scan than CSS files split across the repo.
Decision 2: Variants for state and screen size
Plain CSS uses :hover and @media. Tailwind gives you the same power as prefixes on the class name.
<a class="
text-slate-700
hover:text-slate-900
focus-visible:underline
active:text-slate-950
">Read more</a>
<div class="
grid
grid-cols-1
md:grid-cols-2
lg:grid-cols-3
gap-4
">...</div>
The state prefixes you will reach for daily:
-
hover:,focus:,focus-visible:,active:,disabled: -
group-hover:andpeer-hover:for "when my parent or sibling is hovered" (super useful) -
aria-expanded:,aria-checked:,data-[state=open]:for components driven by ARIA or data attributes -
has-[:invalid]:for the new CSS:has()selector -
not-first:,first:,last:,odd:,even:
The screen prefixes (mobile first):
-
sm:640px and up -
md:768px and up -
lg:1024px and up -
xl:1280px and up -
2xl:1536px and up
Read md:grid-cols-2 as "from 768px and up, use two columns". The bare class is the mobile baseline.
You can stack prefixes:
<button class="
bg-blue-600 hover:bg-blue-500
md:bg-emerald-600 md:hover:bg-emerald-500
">Multi state</button>
Variants are how Tailwind avoids the "one giant CSS file with sixteen media queries" problem. The state lives next to the value.
Decision 3: Dark mode is just another variant
<div class="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100">
...
</div>
In Tailwind v4, dark: defaults to the user's prefers-color-scheme. If you want a manual toggle (a moon icon in your nav), you opt into the class strategy:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
Then <html class="dark"> flips your whole site.
Decision 4: Configuration, the v4 way
Tailwind used to live in a tailwind.config.js. Tailwind v4 (the current major in 2026) moved configuration into CSS itself. You define design tokens with @theme, and Tailwind generates utilities for you automatically.
/* app.css */
@import "tailwindcss";
@theme {
--color-brand: oklch(63% 0.16 256);
--color-brand-50: oklch(96% 0.02 256);
--font-display: "Inter", system-ui, sans-serif;
--spacing-128: 32rem;
--radius-card: 1.25rem;
--breakpoint-3xl: 1920px;
}
Defining --color-brand automatically gives you bg-brand, text-brand, border-brand, ring-brand. Defining --spacing-128 gives you p-128, mx-128, w-128. Defining --breakpoint-3xl gives you 3xl:.
That is the whole config story now. No JS file, no theme.extend, no rebuilding the world.
If you need full power, the JS config still works for backwards compatibility. New projects should start in CSS.
Decision 5: Arbitrary values for the rare one off
Sometimes you need a specific pixel value that is not on the scale. The escape hatch is square brackets:
<div class="w-[317px] mt-[7px] grid-cols-[200px_1fr_60px]">...</div>
<p class="text-[#bada55]">design demanded a specific hex</p>
<div class="bg-[url('/hero.jpg')]">...</div>
Useful, but be honest with yourself. If the same arbitrary value appears more than twice, it should be a token in @theme, not an inline literal.
You can also use modifiers for opacity, font weight, etc:
<div class="bg-blue-500/30 text-slate-900/80">
30% blue background, 80% opaque text
</div>
Decision 6: Composition without losing your mind
Once a button shows up in twenty places with the same long class string, you want to deduplicate. Two senior level moves:
Component in your framework, not in CSS
The Tailwind opinion: a component is a React/Vue/Svelte component, not a .btn CSS class.
// Button.tsx
import { cva, type VariantProps } from "class-variance-authority";
import clsx from "clsx";
const button = cva(
"inline-flex items-center gap-2 rounded-md font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50",
{
variants: {
kind: {
primary: "bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600",
secondary: "bg-slate-200 text-slate-900 hover:bg-slate-300",
ghost: "text-slate-700 hover:bg-slate-100",
},
size: {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-sm",
lg: "px-5 py-2.5 text-base",
},
},
defaultVariants: { kind: "primary", size: "md" },
}
);
type Props = VariantProps<typeof button> &
React.ButtonHTMLAttributes<HTMLButtonElement>;
export function Button({ kind, size, className, ...rest }: Props) {
return <button className={clsx(button({ kind, size }), className)} {...rest} />;
}
The pair you will see most: clsx (or classnames) for conditional class composition, tailwind-merge for safely overriding earlier classes (so <Button className="bg-red-500" /> actually wins), and class-variance-authority for typed component variants.
@apply, the rare exception
If a component is used in plain HTML (no framework), or if you need to style elements you do not control (like markdown HTML), you can extract a class with @apply:
@layer components {
.prose-link {
@apply text-blue-600 underline underline-offset-2 hover:text-blue-500;
}
}
@apply is a tool, not a strategy. If you find yourself reaching for it everywhere, you are fighting Tailwind's grain. Use components instead.
Decision 7: Layouts with utilities
A side by side layout in pure utilities:
<header class="flex items-center justify-between gap-4 p-4 border-b">
<h1 class="text-lg font-semibold">Mia's Blog</h1>
<nav class="flex items-center gap-6 text-sm text-slate-600">
<a class="hover:text-slate-900" href="/">Home</a>
<a class="hover:text-slate-900" href="/about">About</a>
</nav>
</header>
A responsive grid:
<section class="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 p-6">
<article class="rounded-2xl bg-white p-6 shadow">...</article>
<article class="rounded-2xl bg-white p-6 shadow">...</article>
<article class="rounded-2xl bg-white p-6 shadow">...</article>
</section>
A modern landing hero with fluid type:
<section class="
mx-auto max-w-3xl px-6 py-20 text-center
bg-gradient-to-b from-slate-50 to-white
">
<h1 class="text-balance text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight">
A tiny library for tiny lives.
</h1>
<p class="mt-6 text-lg text-slate-600">
Carefully selected books for slow afternoons.
</p>
<div class="mt-10 flex justify-center gap-3">
<button class="rounded-md bg-slate-900 px-5 py-2.5 text-white hover:bg-slate-800">
Browse
</button>
<button class="rounded-md px-5 py-2.5 text-slate-700 hover:bg-slate-100">
Learn more
</button>
</div>
</section>
text-balance is a small modern win. It tells the browser to balance lines so the heading does not orphan a single word on its own line.
Decision 8: Container queries and modern CSS, free
Tailwind v4 ships container queries built in, no plugin needed.
<aside class="@container p-6">
<article class="
rounded-lg bg-white p-4
@md:flex @md:items-center @md:gap-4
">
<img class="h-20 w-20 rounded-full" src="/mochi.jpg" />
<div class="@md:flex-1">
<h3 class="text-lg font-semibold">Mochi</h3>
<p class="text-sm text-slate-600">A small cat with big plans.</p>
</div>
</article>
</aside>
@md: triggers when the container crosses the md size, not the viewport. The same card looks different in a wide and a narrow column without changing its markup.
Other modern bits Tailwind exposes nicely:
-
has-[]for parent based styling:has-[:invalid]:border-red-500 -
group-has-[],peer-has-[]for descendant or sibling based logic -
text-balance,text-prettyfor typography -
scroll-snap-x,snap-mandatory,snap-startfor snap scrolling -
backdrop-blur,backdrop-brightnessfor frosted glass UIs -
mix-blend-multiplyand other blend modes
Decision 9: Theming with CSS variables
Because v4 builds tokens as real CSS variables, runtime theming is free.
@theme {
--color-brand: oklch(63% 0.16 256);
}
:root[data-theme="warm"] { --color-brand: oklch(70% 0.15 30); }
:root[data-theme="cool"] { --color-brand: oklch(70% 0.12 250); }
:root[data-theme="green"] { --color-brand: oklch(70% 0.15 150); }
Now bg-brand follows the data-theme attribute. Switching themes is one attribute change in JavaScript, no rebuild, no class swap on every component.
Decision 10: Forms and prose
Two official plugins worth installing on any content site:
-
@tailwindcss/formsgives all form elements consistent base styles you can build on. Without it, browser defaults are inconsistent across systems. -
@tailwindcss/typographygives you a.proseclass that styles raw HTML (from a CMS or markdown) beautifully.<article class="prose lg:prose-lg dark:prose-invert">{markdown}</article>is the whole setup.
Add them to app.css:
@import "tailwindcss";
@plugin "@tailwindcss/forms";
@plugin "@tailwindcss/typography";
(In v4, plugins are declared in CSS, not in a JS config.)
Decision 11: The headless component story
Tailwind only handles styles. Real apps need accessible behaviors: dropdowns with focus trap, dialogs that handle escape, comboboxes with type ahead. Hand rolling these is a graveyard.
The two friends to know:
- Headless UI (from the Tailwind team): unstyled but accessible primitives. Bring your own utility classes.
- Radix UI (or React Aria on the React side): a more comprehensive set of accessible primitives. Pairs beautifully with Tailwind.
Combine these with cva for variants and you have a small, fast, type safe component library that looks however you want.
Decision 12: Anti patterns, the senior level smell test
A handful of things that will rot your Tailwind codebase:
-
One giant
@applyper component. You are reinventing CSS classes inside Tailwind. Extract a real component instead. -
Random arbitrary values.
w-[317px]once is fine.w-[317px]in 30 places is a missing token. -
Inline string of 60 classes with no structure. Group by purpose: layout, then box, then color, then text, then states. Or use
cva. -
Skipping
tailwind-mergewhen your component acceptsclassName. Without it, callers cannot override. -
Using
@applyfor components used inside React/Vue. You are halfway between two worlds. Pick one. - Hand rolling dropdowns and dialogs instead of using Headless UI or Radix. Accessibility lives there.
-
No design tokens. Without
@themevariables, you lose the "constrained on purpose" benefit.
A peek under the hood
What really happens when Tailwind builds your CSS:
- Tailwind scans your template files (HTML, JSX, Vue, Svelte, etc).
- It finds every class string used.
- For each class it recognizes, it generates the CSS rule that class needs.
- Unused utilities are not generated. The output stylesheet only contains what your code actually uses.
- The JIT (just in time) engine in v4 uses Rust under the hood and a new Lightning CSS based pipeline. Builds that used to take seconds now take milliseconds.
- The output ships as one small CSS file.
That last point is why a "huge utility framework" still produces tiny stylesheets in production. You only get the classes you used. Delete a component, the styles disappear with it. There is no dead CSS.
Tiny tips that will save you later
- Use the official VS Code extension. Autocomplete, hover docs, class sorting. Non negotiable.
-
Use the Prettier plugin (
prettier-plugin-tailwindcss) for canonical class order. Code reviews stop arguing about it. -
Set up
tailwind-mergein any component that acceptsclassName. -
Define design tokens in
@themeon day one. You will use them on day two. -
Mobile first, always. Bare class is mobile.
md:is tablet up. -
Use
cvafor typed component variants. Avoid stringly typedclassNameprops with five conditionals. -
Turn on
text-balancefor headings for free typography wins. -
Wrap content with
max-w-prosefor readable line lengths. - Use Headless UI or Radix for any interactive component (modal, menu, combobox).
- Audit unused arbitrary values every month or so. Promote the repeated ones into the theme.
Wrapping up
So that is the whole story. We were tired of inventing class names that aged into a graveyard. We built a system where styles are tiny single purpose classes, written next to the markup that uses them. Our design tokens are CSS variables defined in @theme. Our utilities respect a constrained scale, so layouts feel consistent across the team. Variants give us state and screen size right where the value is. Dark mode is just another variant. Container queries, :has(), OKLCH colors, and modern features all come along for free.
We learned to compose into framework components (with cva, clsx, tailwind-merge) instead of CSS components, to keep @apply rare, and to lean on Headless UI or Radix for accessible behavior. We got rid of dead CSS forever, because Tailwind only generates the classes our code actually uses.
Once that map is in your head, every Tailwind codebase starts to feel familiar, fast, and shockingly maintainable a year later. Tailwind stops feeling like "ugly inline styles" and starts feeling like the calm, constrained design system every team wishes it had.
Happy styling, and may your stylesheet stay small.
Top comments (0)