The Ultimate 2025 Guide to Tailwind CSS
An LLM handed you a wall of abbreviated classes and you cannot read it. What Tailwind 4 does, what changed when it moved into CSS, and where utilities fail you.
You think you know Tailwind CSS? Maybe you generated a pile of it with an LLM like Claude Code, Gemini, or Cursor. Maybe you tried spinning up a fancy new app in Lovable or Replit. Either way you have a problem: the model spat out garbled, abbreviated classes that look foreign if not incomprehensible.
Why Bother
Admittedly it sounds like a bad idea. Abbreviate a whole pile of CSS into small classes that only sometimes follow the property names.
2 things hold true at once here.
First: use any LLM to generate front-end code and you will end up learning Tailwind, because that trained them and that comes back out. Try asking for good hand-written CSS instead. You will get .card, .card-inner, .card-inner-wrapper, 3 greys that nearly match, and an !important about 40 lines down.
Second: the abbreviations miss the point. p-4 saves you nothing meaningful over padding: 1rem. It gives you 1 of 12 legal values, and that stops a codebase collecting padding: 13px beside padding: 0.8rem beside padding: 1em.
Call Tailwind a design system spelled as classes. People who say they hate it usually mean they hate reading the classes. People who say they love it usually mean they love never arguing about spacing again.
Version 4
Here lies the change that matters, and most of what you will find online still describes version 3.
No more tailwind.config.js. No theme.extend. Your configuration lives in CSS now, in your stylesheet:
@import "tailwindcss";
@theme {
--color-surface: #0b0b0d;
--color-ink: #f4f3f0;
--spacing-gutter: 1.5rem;
--container-prose: 42rem;
--text-eyebrow: 0.75rem;
}
The namespace on the left decides what gets generated. --color-* gives you bg-*, text-*, border-*. --text-* gives you font sizes. --spacing-*, --container-*, --radius-*, --shadow-* and --font-* all behave the same way.
And since these hold real custom properties, hand-written CSS can read them as var(--color-surface). That part people miss. Your theme no longer sits locked inside a build step. It lives on :root.
3 more at-rules worth learning:
@utilityreplaces v3’s@layer utilitiesand theaddUtilitiesplugin. Unlike a plain class, what you define composes with variants.hover:,md:andmotion-safe:all work on it.@variantapplies a variant to a nested block.@custom-variantdefines your own.
That first pair earns its keep, because stacking variants inside @apply gives you legal Tailwind that plenty of tooling cannot parse:
/* Valid. Also a good way to break your linter. */
@utility lifted {
@apply motion-safe:hover:scale-105;
}
/* Nest instead. */
@utility lifted {
@variant motion-safe {
@apply transition-transform duration-500;
}
}
2 Gotchas
Unused theme variables get tree-shaken. Add a token, reference it nowhere, and var(--color-brand) resolves to the empty string. Not your colour. Not an error. Your rule silently does nothing. If a new token seems to do nothing, check that something actually uses it.
Arbitrary values give you an escape hatch. w-[347px] compiles fine and does exactly what the constraint existed to stop. 1 or 2 counts as pragmatism. 40 counts as a design system you quietly abandoned, and you would have done better writing CSS.
The Wrong Tool
Here lies the part no Tailwind guide tells you, and why this site avoids utility classes.
Markdown cannot carry classes.
If a decent chunk of your pages holds prose, whether a blog or docs or anything a CMS stores, that HTML gets generated at build time from Markdown. No <p> sits in your source to hang text-ink leading-relaxed on. You get a <p>, and only a rule in a stylesheet can style it:
.prose p {
margin-block: 1em 0;
color: var(--color-ink);
text-wrap: pretty;
}
Write that rule and you now run 2 systems. Utilities describing components, a stylesheet describing content. Change your body colour and you have to change it twice, and one of them will get forgotten.
Unless both read the same token:
@theme {
--color-ink: light-dark(#1c1a16, #f4f3f0);
--color-ink-muted: light-dark(#575146, #9a978f);
}
/* Generated markup, which can carry no class. */
.prose p {
color: var(--color-ink);
}
/* A component you control, which can. */
@utility quiet {
@apply text-ink-muted;
}
So: express the system as tokens and let both worlds read them. Utilities for one-off layout on an element you control. Plain CSS against var(--color-ink) for anything that also has to style generated markup. What you cannot do: express the site’s system as classes scattered through components and as a parallel stylesheet for the prose.
The @theme block makes this work, and it arrived new in v4. In v3 your theme lived in a javascript file CSS could not read, and you had to pick a side.
Reading The Wall
Read it left to right. It usually falls into 4 groups: layout with flex, grid and absolute; box with p-*, m-*, w-* and gap-*; appearance with bg-*, text-*, border-* and rounded-*; state with hover:, focus-visible: and md:. See the groups and 40 classes stops reading as a wall and starts reading as 4 short sentences.
If it still reads badly, it probably does read badly. 40 classes on 1 element usually means 3 elements wearing a trench coat.