Tailwind Fundamentals
Tailwind is a CSS generator. You write single-purpose class names in your markup, and it produces a stylesheet containing only the classes you actually used. Nothing about the underlying CSS changes — flex is display: flex.
This page covers Tailwind v4, released January 2025, which configures itself in CSS instead of JavaScript. Every place it differs from v3 is called out.
The utility-first idea
Section titled “The utility-first idea”Traditional CSS asks you to name things:
<div class="chat-notification"> <div class="chat-notification-logo-wrapper">…</div> <div class="chat-notification-content">…</div></div>.chat-notification { display: flex; align-items: center; max-width: 24rem; … }Tailwind skips the naming step:
<div class="flex items-center gap-4 max-w-sm rounded-xl bg-white p-6 shadow-lg"> <img class="size-12 shrink-0" src="/logo.svg" alt="" /> <div> <p class="text-xl font-medium text-black">ChitChat</p> <p class="text-gray-500">You have a new message!</p> </div></div>The common objections, and the honest answers
Section titled “The common objections, and the honest answers”“This is just inline styles.”
No. Utilities are constrained to a design system — p-4 can only be one value, and there is no p-4.5px. They support states and breakpoints (hover:, md:), which inline styles cannot. And they are deduplicated in the output: a hundred elements with p-4 produce one rule.
“The markup is ugly.” It is more verbose. That is the trade. What you get back is that the stylesheet stops growing: adding a feature adds zero CSS, and deleting a component deletes all of its styles. In a large codebase this is the difference between a stylesheet that stabilises and one that only grows.
“I’ll repeat myself everywhere.”
You repeat the class strings, not the CSS. The fix is to extract a component in your template language (a React component, an Astro component, a partial), which you were going to do anyway. Extracting a CSS class with @apply is usually the wrong fix — see Tailwind in practice.
“I have to learn a new vocabulary.”
Some. But the names map so directly onto CSS that knowing CSS gets you most of the way: justify-between → justify-content: space-between, items-center → align-items: center. If you don’t know CSS, Tailwind will not save you.
The real payoff: you never invent a class name, never wonder if a rule is still used, and never accidentally break an unrelated page by editing a shared selector. Every style is local to the element it is on.
Setting up Tailwind v4
Section titled “Setting up Tailwind v4”Two moving parts: a build plugin and one line of CSS.
With Vite (recommended)
Section titled “With Vite (recommended)”npm install tailwindcss @tailwindcss/viteimport { defineConfig } from 'vite';import tailwindcss from '@tailwindcss/vite';
export default defineConfig({ plugins: [tailwindcss()],});@import "tailwindcss";Import that CSS file from your entry point and you are done.
With PostCSS
Section titled “With PostCSS”npm install tailwindcss @tailwindcss/postcss postcssexport default { plugins: { '@tailwindcss/postcss': {}, },};With the standalone CLI
Section titled “With the standalone CLI”npm install tailwindcss @tailwindcss/clinpx @tailwindcss/cli -i ./src/app.css -o ./dist/app.css --watchHow this differs from v3
Section titled “How this differs from v3”| v3 | v4 | |
|---|---|---|
| Entry CSS | @tailwind base; @tailwind components; @tailwind utilities; |
@import "tailwindcss"; |
| Config | tailwind.config.js (a JS object) |
@theme { … } in CSS |
| Which files to scan | a content: [...] array you maintain |
detected automatically |
| PostCSS plugin | tailwindcss |
@tailwindcss/postcss |
| Vendor prefixes | autoprefixer in your PostCSS chain |
built in |
@import handling |
postcss-import |
built in |
| Layers | emulated @layer |
native CSS cascade layers |
| Default colours | rgb() palette |
oklch() palette |
| Container queries | @tailwindcss/container-queries plugin |
in core |
Automatic content detection: v4 crawls your project for candidate class names, skipping anything in .gitignore, plus binary files and node_modules. You no longer maintain a content array. To add a source it would not find — a dependency that ships Tailwind classes, or files in an ignored directory — use @source:
@import "tailwindcss";
@source "../node_modules/@my-org/ui-kit";Browser requirements: v4 relies on @property, color-mix(), and cascade layers, so it targets Safari 16.4+, Chrome 111+, and Firefox 128+. If you must support older browsers, stay on v3.4.
Migrating from v3
Section titled “Migrating from v3”There is an automated codemod:
npx @tailwindcss/upgradeRun it on a clean git branch. Notable manual changes it handles or flags:
- Renamed for consistency:
shadow-sm→shadow-xs,shadow→shadow-sm,rounded-sm→rounded-xs,rounded→rounded-sm,blur→blur-sm,outline-none→outline-hidden. - Removed deprecations:
bg-opacity-50→bg-black/50,flex-grow-*→grow-*,flex-shrink-*→shrink-*. - Default border colour changed from
gray-200tocurrentColor; defaultringwidth changed from 3px to 1px. - Important modifier moved to a suffix:
!bg-red-500→bg-red-500!. - Prefix syntax moved into the import:
@import "tailwindcss" prefix(tw);, used astw:flex.
If you have a large JS config you are not ready to port, v4 can still load it:
@import "tailwindcss";@config "../tailwind.config.js";How utilities map to CSS
Section titled “How utilities map to CSS”Each utility is a class containing one or a few declarations. The mapping is mechanical:
| Class | CSS |
|---|---|
block |
display: block |
flex |
display: flex |
hidden |
display: none |
items-center |
align-items: center |
justify-between |
justify-content: space-between |
gap-4 |
gap: calc(var(--spacing) * 4) → 1rem |
p-6 |
padding: 1.5rem |
mt-2 |
margin-top: 0.5rem |
mx-auto |
margin-left: auto; margin-right: auto |
w-full |
width: 100% |
size-12 |
width: 3rem; height: 3rem |
text-sm |
font-size: var(--text-sm) + its line height |
font-semibold |
font-weight: 600 |
text-gray-500 |
color: var(--color-gray-500) |
bg-white |
background-color: #fff |
rounded-lg |
border-radius: var(--radius-lg) |
border |
border-width: 1px |
shadow-md |
box-shadow: var(--shadow-md) |
overflow-hidden |
overflow: hidden |
absolute / relative |
position: absolute / relative |
inset-0 |
inset: 0 |
z-10 |
z-index: 10 |
transition |
transition-property on the common set, 150ms |
Naming conventions that make the rest guessable:
t/r/b/lfor physical sides,x/yfor axes,s/efor logical start/end:pt-4,px-6,ms-2,border-b.- A leading
-negates:-mt-4ismargin-top: -1rem. - A
/sets alpha:bg-black/50,text-white/80,border-white/10. *in the docs means a scale value:text-*covers both font size (text-sm) and colour (text-red-500) — Tailwind disambiguates by whether the value names a colour.
The scales
Section titled “The scales”Spacing
Section titled “Spacing”In v4 the spacing scale is generated from a single variable:
@theme { --spacing: 0.25rem; /* the default */}Every spacing utility is calc(var(--spacing) * N), so p-4 is 1rem, p-8 is 2rem, and — unlike v3 — any integer works. mt-13, gap-17, and w-30 are all valid without configuration. Fractions of the base exist too: p-0.5 is 0.125rem, p-1.5 is 0.375rem.
In v3 the scale was a fixed lookup table in the config, and p-13 simply did not exist unless you added it.
The scale applies to p-*, m-*, gap-*, space-*, w-*, h-*, size-*, inset-*, translate-*, and more. Non-numeric values also exist: w-full, w-screen, w-min, w-max, w-fit, w-1/2, w-px.
Colour
Section titled “Colour”The default palette has 22 hues — slate, gray, zinc, neutral, stone, red, orange, amber, yellow, lime, green, emerald, teal, cyan, sky, blue, indigo, violet, purple, fuchsia, pink, rose — each with 11 steps: 50, 100, 200, … 900, 950. Lower is lighter. Plus black, white, transparent, current (i.e. currentColor), and inherit.
<div class="bg-slate-50 text-slate-900 border border-slate-200">…</div><div class="bg-blue-600 text-white hover:bg-blue-700">…</div><div class="bg-black/50 backdrop-blur-sm">…</div>In v4 these are defined as --color-* theme variables in oklch, which means you can also use them directly in hand-written CSS:
.custom { background: var(--color-blue-600); }Typography
Section titled “Typography”text-xs, text-sm, text-base, text-lg, text-xl, text-2xl … text-9xl. Each also sets a sensible default line-height, which you can override with leading-* (leading-none, leading-tight, leading-relaxed, or any spacing value like leading-6).
Weights: font-thin (100) through font-black (900), with font-normal (400), font-medium (500), font-semibold (600), font-bold (700).
Preflight
Section titled “Preflight”@import "tailwindcss" includes Preflight, a base reset. It removes default margins, unstyles headings and lists, makes images display: block and max-width: 100%, and sets borders to a consistent default. This is why an <h1> in a fresh Tailwind project looks like body text — that is deliberate, so you style everything explicitly.
Variants
Section titled “Variants”A variant is a prefix ending in : that wraps the utility in a condition. This is the mechanism that makes utilities strictly more capable than inline styles.
Responsive
Section titled “Responsive”Breakpoint prefixes are min-width — Tailwind is mobile-first. An unprefixed utility applies at every size; a prefixed one applies from that breakpoint up.
| Prefix | Min width (v4) |
|---|---|
sm: |
40rem (640px) |
md: |
48rem (768px) |
lg: |
64rem (1024px) |
xl: |
80rem (1280px) |
2xl: |
96rem (1536px) |
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">…</div><p class="text-sm md:text-base lg:text-lg">…</p>Arbitrary breakpoints work inline:
<div class="min-[900px]:flex max-[420px]:text-xs">…</div>Container queries are in core in v4 (a plugin in v3). Mark the container with @container, then use @-prefixed variants:
<div class="@container"> <div class="flex flex-col @md:flex-row @md:items-center gap-4">…</div></div>@md here is 28rem of container width, not viewport width, and the scale (@xs … @7xl) is separate from the breakpoint scale.
<button class="bg-blue-600 hover:bg-blue-700 focus-visible:outline-2 active:bg-blue-800 disabled:opacity-50 disabled:cursor-not-allowed"> Save</button>Common state variants: hover:, focus:, focus-visible:, focus-within:, active:, visited:, target:, disabled:, checked:, required:, invalid:, read-only:, placeholder-shown:, autofill:.
Structural: first:, last:, only:, odd:, even:, first-of-type:, empty:.
Pseudo-elements: before:, after:, placeholder:, selection:, marker:, file:, backdrop:. Note that before: and after: automatically add content: "", so before:block before:size-2 before:bg-red-500 works with no extra class.
v4 also adds a not-* variant that negates any other variant:
<li class="not-last:border-b">…</li><button class="not-disabled:hover:bg-slate-100">…</button>Parent and sibling state: group and peer
Section titled “Parent and sibling state: group and peer”Style a child based on a parent’s state by marking the parent group:
<a href="#" class="group block rounded-lg p-4 hover:bg-slate-100"> <h3 class="font-medium group-hover:text-blue-600">Title</h3> <p class="text-slate-500 group-hover:text-slate-700">Description</p></a>Style an element based on a previous sibling’s state with peer:
<input type="email" class="peer border p-2" required /><p class="mt-1 hidden text-sm text-red-600 peer-invalid:block"> Enter a valid email address.</p>peer only works for elements that come after the peer in the DOM, because it compiles to a sibling combinator.
Both support names, so you can nest them without ambiguity:
<div class="group/item"> <div class="group/edit"> <span class="group-hover/item:underline group-hover/edit:text-blue-600">…</span> </div></div>Dark mode
Section titled “Dark mode”In v4, dark: uses prefers-color-scheme out of the box — no configuration:
<div class="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100">…</div>For a manual toggle (a .dark class on <html>), redefine the variant in CSS:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));Or with a data attribute:
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));The v3 equivalent was darkMode: 'class' (or ['class', '[data-theme="dark"]']) in tailwind.config.js.
module.exports = { darkMode: 'class', content: ['./src/**/*.{html,js,jsx,ts,tsx}'], theme: { extend: {} }, plugins: [],};Stacking variants
Section titled “Stacking variants”Variants compose, separated by colons, in any combination:
<button class="dark:md:hover:bg-slate-700">…</button><div class="lg:group-hover:focus-within:opacity-100">…</div><li class="odd:bg-slate-50 dark:odd:bg-slate-800">…</li>Read them left to right as nested conditions: dark:md:hover: means “in dark mode, at md and above, on hover”.
Arbitrary variants
Section titled “Arbitrary variants”If a variant does not exist, write the selector inline in square brackets:
<div class="[&>*]:border-b [&:nth-child(3)]:bg-slate-100">…</div><div class="supports-[display:grid]:grid">…</div><div class="[@media(prefers-reduced-motion:reduce)]:transition-none">…</div>& stands for the element itself. Use these sparingly — they are an escape hatch, not a style.
Key points
Section titled “Key points”- Utilities are CSS, deduplicated and constrained to a scale, with the states and breakpoints that inline styles cannot express.
- v4 setup is one npm plugin plus
@import "tailwindcss";— notailwind.config.js, nocontentarray, no autoprefixer. - v4 needs Safari 16.4+, Chrome 111+, Firefox 128+; stay on v3.4 for anything older.
- The v4 spacing scale is generated from
--spacing, so any integer works. - Breakpoint prefixes are min-width only;
sm:means “640px and up”, never “on phones”. group-*andpeer-*cover parent and previous-sibling state.dark:follows the OS by default in v4; add@custom-variant dark (&:where(.dark, .dark *))for a manual toggle.