Skip to content

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.

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-betweenjustify-content: space-between, items-centeralign-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.

Two moving parts: a build plugin and one line of CSS.

Terminal window
npm install tailwindcss @tailwindcss/vite
vite.config.js
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [tailwindcss()],
});
src/app.css
@import "tailwindcss";

Import that CSS file from your entry point and you are done.

Terminal window
npm install tailwindcss @tailwindcss/postcss postcss
postcss.config.mjs
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};
Terminal window
npm install tailwindcss @tailwindcss/cli
npx @tailwindcss/cli -i ./src/app.css -o ./dist/app.css --watch
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.

There is an automated codemod:

Terminal window
npx @tailwindcss/upgrade

Run it on a clean git branch. Notable manual changes it handles or flags:

  • Renamed for consistency: shadow-smshadow-xs, shadowshadow-sm, rounded-smrounded-xs, roundedrounded-sm, blurblur-sm, outline-noneoutline-hidden.
  • Removed deprecations: bg-opacity-50bg-black/50, flex-grow-*grow-*, flex-shrink-*shrink-*.
  • Default border colour changed from gray-200 to currentColor; default ring width changed from 3px to 1px.
  • Important modifier moved to a suffix: !bg-red-500bg-red-500!.
  • Prefix syntax moved into the import: @import "tailwindcss" prefix(tw);, used as tw: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";

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/l for physical sides, x/y for axes, s/e for logical start/end: pt-4, px-6, ms-2, border-b.
  • A leading - negates: -mt-4 is margin-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.

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.

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); }

text-xs, text-sm, text-base, text-lg, text-xl, text-2xltext-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).

@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.

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.

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>

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>

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:

app.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.

tailwind.config.js — v3 only
module.exports = {
darkMode: 'class',
content: ['./src/**/*.{html,js,jsx,ts,tsx}'],
theme: { extend: {} },
plugins: [],
};

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”.

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.

  • 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"; — no tailwind.config.js, no content array, 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-* and peer-* 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.