Tailwind CSS
Tailwind CSS v4.3 (4.3.3, July 2026): setup, CSS-first configuration, the utility and variant vocabulary, live flexbox, grid, position and animation demos, migrating from v3, and component patterns. The CSS behind the utilities is in CSS, motion in Animation.
Install
v4 targets Safari 16.4+, Chrome 111+ and Firefox 128+ (stay on v3.4 for older browsers). There
is no tailwind.config.js and no content array: one CSS import and automatic source
detection.
| Setup | Packages | Wire-up |
|---|---|---|
| Vite | tailwindcss @tailwindcss/vite | plugins: [tailwindcss()] in vite.config.ts |
| Next.js / PostCSS | tailwindcss @tailwindcss/postcss postcss | postcss.config.mjs |
| webpack (v4.2+) | tailwindcss @tailwindcss/webpack | loader after css-loader |
| CLI | tailwindcss @tailwindcss/cli | bunx @tailwindcss/cli -i in.css -o out.css --watch |
| Bun full-stack | tailwindcss bun-plugin-tailwind | bunfig.toml [serve.static] plugins |
bun add tailwindcss @tailwindcss/postcss postcss # Next.js
bun add -d tailwindcss @tailwindcss/vite # Viteconst config = {
plugins: { "@tailwindcss/postcss": {} },
};
export default config;import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
});[serve.static]
plugins = ["bun-plugin-tailwind"]@import "tailwindcss";For Bun.build, pass the same plugin: plugins: [tailwind] with
import tailwind from "bun-plugin-tailwind". The Vite plugin is faster than PostCSS, and
Tailwind measured the webpack loader at about twice the speed of PostCSS in a large Next.js app.
CSS-first config
| Directive | Does |
|---|---|
@import "tailwindcss"; | theme, preflight and utilities, each in its own cascade layer |
@import "tailwindcss" prefix(tw); | classes become tw:flex, variables --tw-* |
@import "tailwindcss" important; | every utility gets !important |
@theme { --color-brand: ...; } | design tokens: create utilities and CSS variables |
@theme inline { ... } | utilities inline the value, so var() references resolve where used |
@theme static { ... } | emit every variable, even the unused ones |
@source "../packages/ui"; | scan an extra path (git-ignored files and node_modules are skipped) |
@source not "./legacy"; | exclude a path |
@source inline("underline bg-red-{50,{100..900..100}}") | safelist classes (space-separated, brace expansion) |
@import "tailwindcss" source(none); | turn off auto-detection and list sources yourself |
@utility name { ... } | a custom utility that works with every variant |
@variant dark { ... } | apply a variant inside your own CSS; @variant hover, focus and @variant hover:focus since v4.3 |
@custom-variant name (selector); | a new variant; the block form uses @slot |
@apply flex gap-2; | inline utilities into a rule |
@reference "../app.css"; | import theme and utilities for @apply without emitting CSS (CSS modules, Vue, Svelte) |
@plugin "@tailwindcss/typography"; | load a JS plugin |
@config "./tailwind.config.js"; | load a legacy v3 config (no corePlugins, safelist or separator) |
--spacing(4) | theme spacing in CSS: calc(var(--spacing) * 4) |
--alpha(var(--color-x) / 50%) | color with opacity via color-mix |
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@source "../../packages/ui/src";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-display: "Satoshi", sans-serif;
--color-brand-500: oklch(0.62 0.19 255);
--breakpoint-3xl: 120rem;
}
@layer components {
.prose-tight { @apply prose prose-sm max-w-none; }
}Plain CSS still works: put element defaults in @layer base and class-based components in
@layer components, so utilities can override them.
Theme variables
Each @theme variable creates utilities from its namespace and a :root CSS variable
you can use anywhere with var().
| Namespace | Generates | Example |
|---|---|---|
--color-* | bg-*, text-*, border-*, fill-*, ring-*, … | --color-brand-500 gives bg-brand-500 |
--font-* | font-* family | --font-display |
--text-* | text-* size (plus --text-*--line-height) | --text-huge: 5rem |
--font-weight-* | font-* weight | --font-weight-heavy: 850 |
--tracking-* / --leading-* | letter spacing / line height | --leading-snug: 1.3 |
--spacing | the base unit; every p-*, m-*, w-*, gap-* value is a multiple | --spacing: 0.25rem |
--spacing-* | named spacing values | --spacing-gutter: 1.5rem gives p-gutter |
--breakpoint-* | responsive variants | --breakpoint-xs: 30rem gives xs: |
--container-* | @sm: container variants and max-w-* | --container-prose: 65ch |
--radius-* | rounded-* | --radius-card: 1rem |
--shadow-*, --inset-shadow-*, --drop-shadow-*, --text-shadow-* | shadows | --shadow-glow: 0 0 1rem ... |
--blur-* | blur-*, backdrop-blur-* | |
--perspective-*, --aspect-*, --zoom-*, --tab-size-* | 3D, aspect, zoom, tab size | --aspect-cinema: 21 / 9 |
--ease-* | ease-* | --ease-snappy: cubic-bezier(.2,0,0,1) |
--animate-* | animate-* (put @keyframes inside @theme) | --animate-fade: fade 0.2s |
@theme {
--color-*: initial; /* drop the default palette */
--color-ink: oklch(0.2 0.02 260);
--color-paper: oklch(0.99 0 0);
}
@theme {
--*: initial; /* start from an empty theme */
}
@theme inline {
--font-sans: var(--font-inter); /* from next/font */
}// read a token at runtime
const brand = getComputedStyle(document.documentElement)
.getPropertyValue("--color-brand-500");Default colors are oklch() values in 26 palettes (v4.2 added mauve, olive, mist
and taupe), each with shades from 50 to 950.
- 50oklch(97.7% 0.013 236.62)
- 100oklch(95.1% 0.026 236.824)
- 200oklch(90.1% 0.058 230.902)
- 300oklch(82.8% 0.111 230.318)
- 400oklch(74.6% 0.16 232.661)
- 500oklch(68.5% 0.169 237.323)
- 600oklch(58.8% 0.158 241.966)
- 700oklch(50% 0.134 242.749)
- 800oklch(44.3% 0.11 240.79)
- 900oklch(39.1% 0.09 240.876)
- 950oklch(29.3% 0.066 243.157)
- 50oklch(97.9% 0.021 166.113)
- 100oklch(95% 0.052 163.051)
- 200oklch(90.5% 0.093 164.15)
- 300oklch(84.5% 0.143 164.978)
- 400oklch(76.5% 0.177 163.223)
- 500oklch(69.6% 0.17 162.48)
- 600oklch(59.6% 0.145 163.225)
- 700oklch(50.8% 0.118 165.612)
- 800oklch(43.2% 0.095 166.913)
- 900oklch(37.8% 0.077 168.94)
- 950oklch(26.2% 0.051 172.552)
Utilities compile to plain CSS that reads these variables. This is the v4.3 output for three classes (trimmed of its sRGB fallback), running live:
<style>
:root {
--spacing: 0.25rem;
--color-sky-500: oklch(68.5% 0.169 237.323);
}
.p-2 { padding: calc(var(--spacing) * 2); }
.p-4 { padding: calc(var(--spacing) * 4); }
.bg-sky-500 { background-color: var(--color-sky-500); }
.bg-sky-500\/50 {
background-color: color-mix(in oklab,
var(--color-sky-500) 50%, transparent);
}
div { width: fit-content; margin-bottom: 8px;
border-radius: 6px; font: 12px monospace; }
</style>
<div class="p-2 bg-sky-500">p-2 bg-sky-500</div>
<div class="p-4 bg-sky-500/50">p-4 bg-sky-500/50</div>Layout utilities
Flexbox, grid and position utilities have their own sections below, with live demos.
| Area | Utilities |
|---|---|
| Display | block, inline-block, flex, inline-flex, grid, contents, hidden, sr-only, flow-root |
| Overflow | overflow-hidden, overflow-x-auto, overflow-clip, overscroll-contain |
| Container | container (width: 100% plus a max-width per breakpoint; center with mx-auto) |
| Box | box-border, aspect-video, aspect-square, aspect-3/2, object-cover, columns-3 |
| Scroll | snap-x, snap-mandatory, snap-center, scroll-mt-16, scroll-smooth, scrollbar-thin, scrollbar-gutter-stable (v4.3) |
| Spacing & sizing | Note |
|---|---|
p-4, px-2, py-1, ps-3, pe-3 | any integer works: p-13 is calc(var(--spacing) * 13) |
pbs-4, pbe-4, mbs-2, mbe-2 | logical block start and end (v4.2) |
m-auto, mx-auto, -mt-2 | a leading - negates |
w-64, w-1/2, w-full, w-screen, w-fit | fractions are percentages |
size-10 | width and height together |
h-dvh, min-h-svh, h-lvh | dynamic viewport units |
max-w-prose, max-w-md, max-w-screen-lg | max-w-md comes from --container-md |
inline-full, block-64, max-inline-lg | logical sizing (v4.2) |
Style utilities
| Typography | Note |
|---|---|
text-sm, text-base, text-xl/7 | size, with an optional / line height |
font-sans, font-mono, font-medium, font-bold | family and weight |
leading-tight, tracking-wide | line height, letter spacing |
text-balance, text-pretty, text-wrap, text-nowrap | text-wrap values |
truncate, line-clamp-3, text-ellipsis | overflow |
wrap-break-word, wrap-anywhere, break-all | long words and URLs |
tabular-nums, font-features-["ss01"] | numerals; font-features-* since v4.2 |
underline, decoration-wavy, underline-offset-4 | decoration |
text-shadow-sm, text-shadow-lg, text-shadow-black/50 | text shadows (v4.1) |
uppercase, italic, antialiased, whitespace-pre-wrap, tab-4 | misc; tab-* since v4.3 |
| Color | Note |
|---|---|
bg-sky-500, text-gray-950, border-red-200 | palette colors |
bg-sky-500/50, text-black/[.35], bg-brand/(--alpha) | opacity modifier (via color-mix) |
bg-(--brand), text-[#bada55] | a CSS variable / an arbitrary color |
bg-linear-to-r from-red-500 via-50% to-blue-500 | gradients: bg-linear-*, bg-radial, bg-conic |
bg-linear-45, bg-linear-to-r/oklch | gradient angle and interpolation space |
fill-current, stroke-2, caret-pink-500, accent-brand-500 | SVG and form parts |
scheme-dark, scheme-light-dark | color-scheme |
scrollbar-thumb-sky-700, scrollbar-track-sky-100 | scrollbar colors (v4.3) |
| Borders & effects | Note |
|---|---|
border, border-2, border-t, border-bs | width (default color is currentColor) |
rounded-xs … rounded-4xl, rounded-full, rounded-s-lg | radius |
ring-2 ring-brand-500 ring-offset-2, inset-ring | box-shadow rings (default width is 1px) |
outline-2 outline-offset-2, outline-hidden | outlines; outline-hidden keeps one in forced-colors mode |
shadow-xs … shadow-2xl, inset-shadow-sm, shadow-brand-500/20 | shadows, colored shadows |
opacity-50, mix-blend-multiply, blur-sm, backdrop-blur-md | filters |
drop-shadow-lg, drop-shadow-red-500/50 | drop shadow, now with color (v4.1) |
mask-b-from-50%, mask-radial-from-40% | masks (v4.1) |
rotate-45, scale-95, translate-x-2, -translate-y-1/2 | individual transform properties |
rotate-x-12, perspective-near, transform-3d, backface-hidden | 3D transforms |
Flexbox
One-dimensional layout: flex on the parent, then direction, alignment and sizing classes.
Every demo on this page is real Tailwind output, compiled from the classes shown. The CSS
model behind it is in CSS: Flexbox.
| Utility | CSS |
|---|---|
flex, inline-flex | display: flex / inline-flex |
flex-row, flex-row-reverse, flex-col, flex-col-reverse | flex-direction |
flex-wrap, flex-nowrap, flex-wrap-reverse | flex-wrap |
justify-start, -center, -end, -between, -around, -evenly, -stretch | justify-content (main axis) |
justify-center-safe, justify-end-safe | safe center: never pushes overflow off the start edge (v4.1) |
items-start, -center, -end, -stretch, -baseline, -baseline-last | align-items (cross axis; default stretch) |
content-start, content-between, … | align-content: spacing between wrapped lines |
self-auto, self-start, self-center, self-end, self-stretch | align-self on one item |
gap-4, gap-x-6, gap-y-2 | gap between items (not at the edges) |
flex-1 | flex: 1: share free space, ignoring content size |
flex-auto | flex: auto: grow and shrink from the content size |
flex-initial | flex: 0 auto: shrink but never grow (the default) |
flex-none | flex: none: rigid |
grow, grow-0, grow-3, shrink, shrink-0 | flex-grow, flex-shrink |
basis-64, basis-1/3, basis-full, basis-(--w) | flex-basis |
order-1, order-first, order-last, -order-1 | order (visual only; tab order stays the DOM order) |
ms-auto / ml-auto | pushes the item and everything after it to the end |
min-w-0 | lets an item shrink below its content so truncate works |
<style>
i { width: 36px; height: 16px; border-radius: 3px;
background: var(--graph-0); }
</style>
<div class="grid grid-cols-[7.5rem_1fr] items-center
gap-1.5 font-mono text-xs">
<span>justify-start</span>
<div class="flex justify-start gap-1 rounded
bg-(--chip) p-1"><i></i><i></i><i></i></div>
<span>justify-center</span>
<div class="flex justify-center gap-1 rounded
bg-(--chip) p-1"><i></i><i></i><i></i></div>
<span>justify-between</span>
<div class="flex justify-between rounded bg-(--chip)
p-1"><i></i><i></i><i></i></div>
<span>justify-around</span>
<div class="flex justify-around rounded bg-(--chip)
p-1"><i></i><i></i><i></i></div>
<span>justify-evenly</span>
<div class="flex justify-evenly rounded bg-(--chip)
p-1"><i></i><i></i><i></i></div>
</div>items-* aligns items of different heights across the row; baseline lines up the text:
<style>
.row > * { width: 44px; border-radius: 3px;
background: var(--graph-2); color: var(--bg);
text-align: center; font-size: 11px; }
</style>
<div class="grid grid-cols-5 gap-2 font-mono text-xs">
<span>start</span><span>center</span><span>end</span>
<span>stretch</span><span>baseline</span>
<div class="row flex h-24 items-start gap-1 rounded
bg-(--chip) p-1"><b class="h-6">a</b>
<b class="h-12">b</b></div>
<div class="row flex h-24 items-center gap-1 rounded
bg-(--chip) p-1"><b class="h-6">a</b>
<b class="h-12">b</b></div>
<div class="row flex h-24 items-end gap-1 rounded
bg-(--chip) p-1"><b class="h-6">a</b>
<b class="h-12">b</b></div>
<div class="row flex h-24 items-stretch gap-1 rounded
bg-(--chip) p-1"><b>a</b><b>b</b></div>
<div class="row flex h-24 items-baseline gap-1 rounded
bg-(--chip) p-1"><b class="pt-4">a</b>
<b class="text-lg">b</b></div>
</div>How items share the row: flex-1 splits the free space, flex-none keeps its size,
basis-1/3 starts from a third, and grow takes whatever is left.
<style>
.row > * { padding: 4px 6px; border-radius: 3px;
color: var(--bg); white-space: nowrap; }
</style>
<div class="space-y-2 font-mono text-xs *:flex *:gap-1
*:rounded *:bg-(--chip) *:p-1">
<div class="row">
<b class="flex-1 bg-(--graph-0)">flex-1</b>
<b class="flex-1 bg-(--graph-0)">flex-1</b>
<b class="flex-none bg-(--graph-1)">flex-none</b></div>
<div class="row"><b class="basis-1/3 bg-(--graph-3)">
basis-1/3</b>
<b class="grow bg-(--graph-2)">grow</b></div>
<div class="row"><b class="bg-(--graph-1)">logo</b>
<b class="bg-(--graph-0)">link</b>
<b class="ms-auto bg-(--graph-2)">ms-auto</b></div>
<div class="row"><b class="order-last bg-(--graph-3)">
1 order-last</b><b class="bg-(--graph-0)">2</b>
<b class="bg-(--graph-0)">3</b></div>
</div>flex-wrap lets items break onto new lines; gap spaces both directions:
<div class="flex flex-wrap gap-2 rounded bg-(--chip) p-2
font-mono text-xs *:rounded *:bg-(--graph-0) *:px-2
*:py-1 *:text-(--bg)">
<span>html</span><span>css</span><span>tailwind</span>
<span>flexbox</span><span>grid</span><span>position</span>
<span>transition</span><span>animation</span>
<span>container queries</span><span>dark mode</span>
</div><nav class="flex items-center gap-4 px-4 py-2">
<a class="font-bold">Logo</a>
<a>Docs</a><a>Blog</a>
<button class="ms-auto">Sign in</button>
</nav>
<article class="flex gap-3">
<img class="size-12 shrink-0 rounded-full" src="a.png">
<div class="min-w-0">
<h3 class="truncate font-semibold">A long title</h3>
<p class="line-clamp-2">Body text…</p>
</div>
</article>Grid
Two-dimensional layout: define columns (and optionally rows) on the parent, then place and span items. The CSS model is in CSS: Grid.
| Utility | CSS |
|---|---|
grid, inline-grid | display: grid |
grid-cols-3 | grid-template-columns: repeat(3, minmax(0, 1fr)) |
grid-cols-[14rem_1fr], grid-cols-(--cols) | any track list (_ is a space) or a variable |
grid-cols-[repeat(auto-fit,minmax(8rem,1fr))] | as many columns as fit, no breakpoints |
grid-rows-3, grid-rows-[auto_1fr_auto] | grid-template-rows |
grid-cols-subgrid, grid-rows-subgrid | reuse the parent grid's tracks |
col-span-2, col-span-full | grid-column: span 2 / span 2, 1 / -1 |
col-start-2, col-end-4, -col-end-1 | start and end lines (-1 is the last line) |
col-[2/4], col-2 | the grid-column shorthand |
row-span-2, row-start-1, row-end-3 | the same for rows |
grid-flow-row, grid-flow-col, grid-flow-dense | grid-auto-flow; dense backfills holes |
auto-rows-fr, auto-rows-[minmax(4rem,auto)], auto-cols-min | size of implicit tracks |
gap-4, gap-x-6, gap-y-2 | gutters between tracks |
justify-items-*, items-*, place-items-center | align every item inside its cell |
justify-self-*, self-*, place-self-center | align one item |
justify-*, content-*, place-content-center | align the whole grid inside the container |
[grid-template-areas:'a_b'], [grid-area:a] | named areas: arbitrary properties (no built-in utility) |
<div class="grid grid-cols-4 gap-1.5 font-mono text-xs
*:rounded *:bg-(--chip) *:p-2">
<div class="col-start-2 col-span-2 bg-(--graph-0)!
text-(--bg)">col-start-2 col-span-2</div>
<div class="col-span-full bg-(--graph-2)! text-(--bg)">
col-span-full</div>
<div class="row-span-2 bg-(--graph-3)! text-(--bg)">
row-span-2</div>
<div>4</div><div>5</div><div>6</div>
<div class="col-start-2 -col-end-1 bg-(--graph-1)!
text-(--bg)">col-start-2 -col-end-1</div>
</div>Drag the bottom-right corner of the box: auto-fit columns reflow without any breakpoint.
<div class="h-40 w-full max-w-full resize-x overflow-auto
rounded border border-dashed border-(--muted) p-2">
<div class="grid
grid-cols-[repeat(auto-fit,minmax(5rem,1fr))] gap-2
font-mono text-xs *:rounded *:bg-(--graph-0) *:p-2
*:text-(--bg)">
<div>1</div><div>2</div><div>3</div><div>4</div>
<div>5</div><div>6</div><div>7</div>
</div>
</div>grid-flow-dense moves later small items into holes left by wide ones (so the visual order
no longer matches the DOM):
<div class="grid grid-cols-2 gap-3 font-mono text-xs">
<span>grid-flow-row</span><span>grid-flow-dense</span>
<div class="grid grid-cols-3 gap-1 *:rounded
*:bg-(--graph-0) *:p-1 *:text-(--bg)">
<b>1</b><b class="col-span-2">2 wide</b>
<b class="col-span-2">3 wide</b>
<b class="col-span-2">4 wide</b>
<b>5</b></div>
<div class="grid grid-flow-dense grid-cols-3 gap-1
*:rounded *:bg-(--graph-2) *:p-1 *:text-(--bg)">
<b>1</b><b class="col-span-2">2 wide</b>
<b class="col-span-2">3 wide</b>
<b class="col-span-2">4 wide</b>
<b>5</b></div>
</div>place-items-* aligns every item in its cell; place-self-* overrides one:
<div class="grid grid-cols-3 gap-2 font-mono text-xs">
<span>place-items-start</span>
<span>place-items-center</span>
<span>+ place-self-end</span>
<div class="grid h-20 grid-cols-2 place-items-start gap-1
rounded bg-(--chip) p-1 *:size-6 *:rounded
*:bg-(--graph-3)"><i></i><i></i></div>
<div class="grid h-20 grid-cols-2 place-items-center gap-1
rounded bg-(--chip) p-1 *:size-6 *:rounded
*:bg-(--graph-3)"><i></i><i></i></div>
<div class="grid h-20 grid-cols-2 place-items-center gap-1
rounded bg-(--chip) p-1 *:size-6 *:rounded
*:bg-(--graph-3)"><i></i>
<i class="place-self-end bg-(--graph-1)!"></i></div>
</div>With grid-rows-subgrid, each card's title, body and footer line up with its neighbors',
however long the text is:
<div class="grid grid-cols-3 gap-x-2 text-xs">
<article class="row-span-3 grid grid-rows-subgrid gap-1
rounded bg-(--chip) p-2">
<b>Short</b><p>One line.</p>
<a class="text-(--graph-0)">Read</a></article>
<article class="row-span-3 grid grid-rows-subgrid gap-1
rounded bg-(--chip) p-2">
<b>A much longer title that wraps</b><p>Two lines of
body text here.</p><a class="text-(--graph-0)">Read</a>
</article>
<article class="row-span-3 grid grid-rows-subgrid gap-1
rounded bg-(--chip) p-2">
<b>Mid title</b><p>Body.</p>
<a class="text-(--graph-0)">Read</a></article>
</div>A page shell that switches layout by its own width. The @container queries react to the
box, not the window, so drag the corner to see all three layouts:
<div class="@container h-56 w-full max-w-full resize-x
overflow-auto rounded border border-dashed
border-(--muted) p-1">
<div class="grid h-full gap-1 font-mono text-xs
*:rounded *:p-2 *:text-(--bg)
grid-rows-[auto_1fr_auto]
@sm:grid-cols-[6rem_1fr] @lg:grid-cols-[6rem_1fr_6rem]">
<header class="bg-(--graph-0) @sm:col-span-full">
header</header>
<nav class="bg-(--graph-2) max-sm:hidden">nav</nav>
<main class="bg-(--graph-3)">main</main>
<aside class="hidden bg-(--graph-1) @lg:block">
aside</aside>
<footer class="bg-(--graph-0) @sm:col-span-full">
footer</footer>
</div>
</div>Positions
position utilities plus inset-*, top-*, right-*, bottom-*, left-* offsets and
z-* stacking. The CSS rules (containing blocks, stacking contexts, sticky gotchas) are in
CSS: Positioning.
| Utility | CSS | Offsets from |
|---|---|---|
static | position: static | ignores offsets and z-* |
relative | position: relative | its own place (space kept); anchors absolute children |
absolute | position: absolute | the nearest non-static ancestor |
fixed | position: fixed | the viewport (or an ancestor with a transform or filter) |
sticky | position: sticky | the scrolling ancestor, once scrolled past top-* |
| Offset utility | CSS |
|---|---|
inset-0 | inset: 0 (all four sides) |
inset-x-0, inset-y-0 | inset-inline: 0, inset-block: 0 |
top-0, right-4, bottom-2, left-1/2 | one side; fractions are percentages |
-top-2, -right-2 | negative: pokes out of the parent (badges) |
top-full, left-full | 100%: just past the edge (menus, tooltips) |
inset-s-0, inset-e-4 | inset-inline-start / -end, which flip in RTL (v4.2; start-*/end-* still work) |
top-[117px], top-(--header-h) | arbitrary value / variable |
-translate-1/2, -translate-x-1/2 | center on the offset point |
z-10, z-50, z-auto, -z-10, z-[5] | z-index (needs a position other than static) |
isolate | isolation: isolate: a new stacking context without a z-index |
<div class="relative h-36 rounded bg-(--chip) font-mono
text-[11px] *:absolute *:rounded *:px-1.5 *:py-0.5
*:text-(--bg)">
<span class="top-2 left-2 bg-(--graph-0)">
top-2 left-2</span>
<span class="top-2 right-2 bg-(--graph-0)">
top-2 right-2</span>
<span class="-top-2 left-1/2 bg-(--graph-1)">-top-2</span>
<span class="top-1/2 left-1/2 -translate-1/2
bg-(--graph-2)">top-1/2 left-1/2 -translate-1/2</span>
<span class="inset-x-0 bottom-0 rounded-t-none
bg-(--graph-3) text-center">inset-x-0 bottom-0</span>
</div>sticky top-0 headers stick to the top of their scrolling box, each until its own section
scrolls away. Scroll inside the list:
<div class="h-36 overflow-auto rounded border
border-(--muted) text-sm">
<h4 class="sticky top-0 bg-(--graph-0) px-2
text-(--bg)">A</h4>
<p class="px-2 py-1">Ada</p><p class="px-2 py-1">Alan</p>
<p class="px-2 py-1">Anita</p>
<h4 class="sticky top-0 bg-(--graph-2) px-2
text-(--bg)">B</h4>
<p class="px-2 py-1">Barbara</p>
<p class="px-2 py-1">Bjarne</p>
<p class="px-2 py-1">Brendan</p>
<h4 class="sticky top-0 bg-(--graph-3) px-2
text-(--bg)">G</h4>
<p class="px-2 py-1">Grace</p>
<p class="px-2 py-1">Guido</p>
<p class="px-2 py-1">Gordon</p>
</div>z-* orders overlapping siblings. isolate makes a new stacking context, so the z-50
inside it only competes with its own siblings and stays under the z-10 box next to it:
<div class="relative h-32 font-mono text-[11px]
*:absolute *:h-16 *:w-40 *:rounded *:p-1.5
*:text-(--bg)">
<div class="top-2 left-2 z-20 bg-(--graph-0)">z-20</div>
<div class="top-8 left-16 z-10 bg-(--graph-1)">z-10</div>
<div class="top-4 left-56 isolate bg-(--chip)
text-(--fg)!">isolate
<b class="absolute top-8 left-4 z-50 rounded
bg-(--graph-2) px-1.5 text-(--bg)">z-50</b></div>
<div class="top-14 left-64 z-10 bg-(--graph-3)">z-10</div>
</div>A dropdown: the menu is absolute top-full inside a relative wrapper and shows while the
wrapper is hovered or holds focus. Hover the button:
<div class="group relative inline-block text-sm">
<button class="rounded bg-(--graph-0) px-3 py-1
text-(--bg)">Menu ▾</button>
<ul class="absolute top-full left-0 z-10 mt-1 hidden w-36
rounded border border-(--muted) bg-(--bg) py-1
shadow-lg group-hover:block group-focus-within:block
*:px-3 *:py-1 *:hover:bg-(--chip)">
<li>Profile</li><li>Settings</li><li>Sign out</li>
</ul>
</div><div class="fixed inset-0 z-50 grid place-items-center
bg-black/40">
<div class="w-full max-w-md rounded-lg bg-white
p-6">…</div>
</div>
<button class="relative">
Inbox
<span class="absolute -top-1 -right-2 rounded-full
bg-red-500 px-1.5 text-xs text-white">3</span>
</button>
<header class="sticky top-0 z-40 bg-white/80
backdrop-blur">…</header>Animations
Transitions for state changes, animate-* for loops, starting: for entry, and
motion-safe:/motion-reduce: to respect the user's setting. Principles, easing and
performance are covered in Animation.
| Utility | Note |
|---|---|
transition | colors, opacity, shadow, transforms, filters; 150ms with ease-in-out by default |
transition-colors, transition-opacity, transition-transform, transition-all, transition-none | property sets |
transition-[height,opacity] | arbitrary list |
transition-discrete | transition-behavior: allow-discrete, for display and hidden |
duration-75 … duration-1000, duration-[2s] | transition-duration |
delay-150, delay-300 | transition-delay |
ease-linear, ease-in, ease-out, ease-in-out, ease-(--my-ease) | timing function (from --ease-*) |
animate-spin, animate-ping, animate-pulse, animate-bounce | built-in keyframes |
animate-[wiggle_1s_ease-in-out_infinite], animate-(--my-anim) | arbitrary |
starting:opacity-0 | @starting-style: the state an element enters from |
motion-safe:animate-bounce, motion-reduce:transition-none | reduced-motion gates |
will-change-transform | layer promotion |
Hover the box. The same move with five durations, then the four easings at duration-1000:
<div class="group grid grid-cols-[7rem_1fr] items-center
gap-1 rounded bg-(--chip) p-2 font-mono text-xs
*:even:h-3 *:even:w-8 *:even:rounded-sm
*:even:transition-transform
*:even:group-hover:translate-x-60">
<span>duration-150</span><i class="bg-(--graph-0)
duration-150"></i>
<span>duration-300</span><i class="bg-(--graph-0)
duration-300"></i>
<span>duration-700</span><i class="bg-(--graph-0)
duration-700"></i>
<span>ease-linear</span><i class="bg-(--graph-3)
duration-1000 ease-linear"></i>
<span>ease-in</span><i class="bg-(--graph-1)
duration-1000 ease-in"></i>
<span>ease-out</span><i class="bg-(--graph-2)
duration-1000 ease-out"></i>
<span>ease-in-out</span><i class="bg-(--graph-0)
duration-1000 ease-in-out"></i>
</div>The four built-in animations, live:
<div class="flex items-end justify-around font-mono
text-xs *:grid *:justify-items-center *:gap-3">
<div><i class="size-8 animate-spin rounded-md
border-4 border-(--graph-0) border-t-transparent"></i>
animate-spin</div>
<div><span class="relative flex size-8"><i class="absolute
inset-0 animate-ping rounded-full bg-(--graph-1)/60"></i>
<i class="size-8 rounded-full bg-(--graph-1)"></i></span>
animate-ping</div>
<div><i class="h-8 w-20 animate-pulse rounded
bg-(--muted)"></i>animate-pulse</div>
<div><i class="size-8 animate-bounce rounded-full
bg-(--graph-2)"></i>animate-bounce</div>
</div>Custom animations go in @theme: the --animate-* variable creates the animate-* class,
and its @keyframes sit inside the same block:
<style>
@theme {
--animate-wiggle: wiggle 1s ease-in-out infinite;
--animate-slide: slide 2s var(--ease-in-out) infinite;
@keyframes wiggle {
0%, 100% { rotate: -6deg; }
50% { rotate: 6deg; }
}
@keyframes slide {
50% { translate: 12rem 0; }
}
}
</style>
<div class="flex items-center gap-8 font-mono text-xs">
<span class="animate-wiggle rounded bg-(--graph-3) px-2
py-1 text-(--bg)">animate-wiggle</span>
<span class="animate-slide rounded bg-(--graph-0) px-2
py-1 text-(--bg)">animate-slide</span>
</div>Entry transitions without JavaScript: the items switch from hidden to shown on hover, and
starting: gives the state they fade and slide in from. transition-discrete lets
display take part in the transition.
<div class="group rounded bg-(--chip) p-2 text-sm">
Hover me
<ul class="mt-2 space-y-1 *:hidden *:rounded
*:bg-(--graph-0) *:px-2 *:text-(--bg)
*:transition-all *:transition-discrete *:duration-300
*:group-hover:block *:starting:group-hover:opacity-0
*:starting:group-hover:-translate-x-4">
<li>one</li><li class="delay-75">two</li>
<li class="delay-150">three</li>
</ul>
</div>| Reduced motion | Pattern |
|---|---|
| Only animate when allowed | motion-safe:animate-bounce, motion-safe:transition |
| Switch off for people who asked | motion-reduce:animate-none, motion-reduce:transition-none |
| Swap movement for a fade | transition motion-safe:hover:-translate-y-1 hover:opacity-90 |
Variants
Variants stack left to right, like selectors read: dark:md:hover:bg-x.
| Variant | Applies when |
|---|---|
hover: | hovered, only on devices that can hover (@media (hover: hover)) |
focus:, focus-visible:, focus-within: | focus states |
active:, visited:, target: | |
disabled:, checked:, indeterminate:, required:, read-only: | form states |
invalid:, user-invalid:, user-valid: | validation; user-* waits for interaction (v4.1) |
placeholder-shown:, autofill:, open: | open: covers details, dialog and popovers |
first:, last:, odd:, even:, only:, nth-3:, nth-[2n+1]: | structural |
*:, **: | direct children / all descendants |
before:, after:, placeholder:, file:, marker:, selection:, backdrop:, details-content: | pseudo-elements |
group-hover:, group-focus/name: | an ancestor marked group or group/name |
peer-checked:, peer-invalid/email: | a previous sibling marked peer |
in-focus: | any ancestor matches, without needing group |
has-checked:, has-[img]:, group-has-checked: | :has() |
not-hover:, not-first:, not-supports-[display:grid]: | negate any variant |
data-open:, data-[state=open]:, data-[size=lg]: | data attributes |
aria-expanded:, aria-[sort=ascending]: | ARIA state |
supports-[display:grid]:, supports-backdrop-filter: | @supports |
starting: | @starting-style (entry transitions) |
inert:, noscript:, print:, portrait:, rtl:, ltr: | misc |
pointer-fine:, pointer-coarse:, any-pointer-coarse: | input type (v4.1) |
motion-safe:, motion-reduce:, contrast-more:, forced-colors:, inverted-colors: | user preferences |
dark: | prefers-color-scheme: dark unless you override it |
[&.is-active]:, [&>svg]:size-4 | arbitrary selector variant |
Responsive and container queries
| Variant | Means |
|---|---|
sm: md: lg: xl: 2xl: | width >= 40rem, 48rem, 64rem, 80rem, 96rem (mobile first) |
max-md: | width < 48rem |
md:max-xl: | a range |
min-[900px]:, max-[600px]: | one-off breakpoints |
@container, @container/card | make a container (inline-size); named |
@container-size | a size container, for cqb/cqh (v4.3) |
@3xs: … @7xl: | container width >= --container-* (@sm is 24rem, @7xl 80rem) |
@max-md:, @min-[30rem]: | max-width / arbitrary container queries |
@lg/card: | query the named container |
Dark mode strategies
| Strategy | Setup |
|---|---|
| OS preference (default) | nothing: dark: uses prefers-color-scheme |
| Class | @custom-variant dark (&:where(.dark, .dark *)); |
| Data attribute | @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); |
Tokens instead of dark: | swap --color-* variables under .dark and use bg-surface everywhere |
Arbitrary values & properties
| Syntax | Example |
|---|---|
utility-[value] | top-[117px], grid-cols-[1fr_2fr] (_ is a space) |
utility-(--var) | bg-(--brand) is var(--brand) (v3's bg-[--brand]) |
| type hint | text-(length:--size) vs text-(color:--ink) |
[property:value] | [mask-type:luminance], [--gutter:2rem] |
\_ | a literal underscore: content-['a\_b'] |
| arbitrary variant | [&:nth-child(3)]:underline, [@media(hover:none)]:p-4 |
! suffix | flex! for !important (v3 used a ! prefix) |
- prefix | -mt-4, -translate-x-1/2 negatives |
calc | w-[calc(100%-2rem)] (no spaces inside, or use _) |
Classes must be complete strings in source. bg-${color}-500 is never generated; map to
full class names instead:
const tone = {
info: "bg-sky-100 text-sky-900",
danger: "bg-red-100 text-red-900",
} as const satisfies Record<string, string>;Migrating v3 to v4
bunx @tailwindcss/upgrade # run on a clean git branchThe tool (it needs Node 20+ installed) moves the JS config into CSS, renames classes in templates and swaps the PostCSS plugin.
| v3 | v4 |
|---|---|
@tailwind base; @tailwind components; @tailwind utilities; | @import "tailwindcss"; |
tailwind.config.js theme.extend | @theme { ... } in CSS |
content: [...] | automatic detection, plus @source |
safelist | @source inline(...) |
plugins: [require(...)] | @plugin "..." |
@layer utilities { .x {} } | @utility x { } |
darkMode: "class" | @custom-variant dark (...) |
tailwindcss PostCSS plugin | @tailwindcss/postcss |
npx tailwindcss | npx @tailwindcss/cli |
shadow-sm / shadow | shadow-xs / shadow-sm |
rounded-sm / rounded | rounded-xs / rounded-sm |
blur-sm / blur, drop-shadow-sm / drop-shadow | blur-xs / blur-sm, drop-shadow-xs / drop-shadow-sm |
outline-none | outline-hidden (outline-none now sets outline-style: none) |
ring (3px, blue) | ring-3 (bare ring is 1px, currentColor) |
bg-opacity-50, text-opacity-* | bg-black/50 |
flex-shrink-0, flex-grow | shrink-0, grow |
overflow-ellipsis | text-ellipsis |
bg-gradient-to-r | bg-linear-to-r |
!flex | flex! |
bg-[--brand] | bg-(--brand) |
first:*:pt-0 (right to left) | *:first:pt-0 (left to right) |
grid-cols-[1fr,2fr] | grid-cols-[1fr_2fr] |
theme(colors.red.500) | var(--color-red-500) |
resolveConfig() in JS | getComputedStyle(...).getPropertyValue("--color-x") |
transform-none | scale-none, rotate-none, translate-none |
start-0 / end-0 | inset-s-0 / inset-e-0 (deprecated in v4.2) |
| Behavior change | Fix |
|---|---|
Default border color is currentColor (was gray-200) | always add a color: border border-gray-200 |
space-* and divide-* target :not(:last-child) | prefer flex/grid with gap |
hover: needs (hover: hover) | touch devices no longer get sticky hover |
Buttons use cursor: default | add cursor-pointer or a base rule |
| Placeholder is the text color at 50% | set placeholder:text-gray-400 |
The hidden attribute beats display utilities | remove hidden to show the element |
| No Sass, Less or Stylus | Tailwind handles imports, nesting and vendor prefixes |
@apply in CSS modules or Vue/Svelte styles | add @reference "../app.css"; |
Component patterns
cn(): merge classes safely
clsx builds conditional strings and tailwind-merge resolves conflicts, so the last
p-* wins.
bun add clsx tailwind-mergeimport { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
cn("px-2 py-1", { "opacity-50": false }, "px-4");
// "py-1 px-4"tailwind-merge v3 supports v4 classes. Custom theme keys that don't follow the default
naming need extendTailwindMerge.
tailwind-variants
tv() is like cva with slots, and it merges conflicts itself.
import { tv, type VariantProps } from "tailwind-variants";
export const card = tv({
slots: {
base: "rounded-xl border p-4",
title: "font-semibold",
},
variants: {
tone: {
plain: { base: "bg-white" },
brand: { base: "bg-brand-500", title: "text-white" },
},
},
defaultVariants: { tone: "plain" },
});
type CardVariants = VariantProps<typeof card>;
const { base, title } = card({ tone: "brand" });| Library | Pick it for |
|---|---|
class-variance-authority (cva) | small, framework-agnostic variants (shadcn/ui uses it) |
tailwind-variants (tv) | slots, extend, responsive variants, built-in merge |
clsx + tailwind-merge | ad-hoc conditional classes and className overrides |
Tooling & performance
bun add -d prettier prettier-plugin-tailwindcss{
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./app/globals.css",
"tailwindFunctions": ["cn", "cva", "tv", "clsx"]
}The plugin sorts classes into Tailwind's order. It has to be the last plugin in the list;
tailwindStylesheet points it at your v4 entry CSS so custom utilities sort correctly.
| Topic | Note |
|---|---|
| Engine | Rust (Oxide) plus Lightning CSS: full builds take milliseconds, incremental ones microseconds |
| Output | only the classes found in sources; most projects ship under 10 kB gzipped |
| Cascade layers | theme, base, components, utilities: unlayered CSS beats all of them |
| Variables | theme tokens are real CSS variables, so you can read them in JS or theme at runtime |
| Detection | scans text files, not an AST: keep class names whole and literal |
| Monorepos | add @source "../../packages/ui" for components that live outside the app |
| Editor | the Tailwind CSS IntelliSense extension reads your CSS entry file |
app/globals.css # @import "tailwindcss", @theme, variantslayout.tsx # imports globals.css + next/font varsstyles/tokens.css # @theme { --color-*, --font-* }utilities.css # @utility ...components/ui/button.tsx # cva variantslib/cn.ts # clsx + tailwind-mergepostcss.config.mjs # @tailwindcss/postcss.prettierrc # prettier-plugin-tailwindcssRecipes
Design tokens in @theme
Semantic tokens that switch with the theme; utilities stay the same in both modes.
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
:root {
--surface: oklch(0.99 0 0);
--ink: oklch(0.21 0.02 260);
--accent: oklch(0.62 0.19 255);
}
.dark {
--surface: oklch(0.18 0.01 260);
--ink: oklch(0.94 0 0);
}
@theme inline {
--color-surface: var(--surface);
--color-ink: var(--ink);
--color-accent: var(--accent);
--radius-card: 0.75rem;
}
/* bg-surface text-ink rounded-card border-accent/30 */Class-based dark mode toggle
Three states (light, dark, system); run the same logic inline in head to avoid a flash.
export type Theme = "light" | "dark" | "system";
const media = "(prefers-color-scheme: dark)";
export function applyTheme(theme: Theme): void {
const dark =
theme === "dark" ||
(theme === "system" && matchMedia(media).matches);
document.documentElement.classList.toggle("dark", dark);
if (theme === "system") localStorage.removeItem("theme");
else localStorage.setItem("theme", theme);
}
export function storedTheme(): Theme {
const t = localStorage.getItem("theme");
return t === "light" || t === "dark" ? t : "system";
}Custom utility
Static and functional utilities; both work with every variant. --default() needs v4.3.
@utility content-auto {
content-visibility: auto;
}
/* stack (gap 4), stack-2, stack-[3px] */
@utility stack-* {
display: flex;
flex-direction: column;
gap: --spacing(--value(integer, --default(4)));
gap: --value([length]);
}Button variants with cva
Typed variants plus className overrides merged through cn().
import { cva, type VariantProps } from
"class-variance-authority";
import type { ComponentProps } from "react";
import { cn } from "@/lib/cn";
const button = cva(
"inline-flex items-center gap-2 rounded-lg font-medium " +
"transition-colors focus-visible:outline-2 " +
"disabled:pointer-events-none disabled:opacity-50",
{
variants: {
intent: {
primary:
"bg-brand-500 text-white hover:bg-brand-600",
ghost: "hover:bg-black/5 dark:hover:bg-white/10",
},
size: { sm: "h-8 px-3 text-sm", md: "h-10 px-4" },
},
defaultVariants: { intent: "primary", size: "md" },
},
);
type Props = ComponentProps<"button"> &
VariantProps<typeof button>;
export function Button(props: Props) {
const { className, intent, size, ...rest } = props;
const cls = cn(button({ intent, size }), className);
return <button className={cls} {...rest} />;
}Container-query card
The card switches to a horizontal layout when its own slot is wide enough, wherever it's used.
export function ProductCard(props: {
title: string;
image: string;
}) {
return (
<div className="@container">
<article className="grid gap-4 rounded-xl border p-4
@md:grid-cols-[10rem_1fr] @md:items-center">
<img src={props.image} alt=""
className="aspect-square w-full rounded-lg
object-cover" />
<h3 className="text-base font-semibold text-balance
@md:text-lg">{props.title}</h3>
</article>
</div>
);
}Animations with @keyframes in @theme
Custom animate-* utilities; @starting-style via starting: covers simple entry fades.
@theme {
--animate-fade-up: fade-up 0.3s var(--ease-out) both;
--animate-wiggle: wiggle 1s ease-in-out infinite;
@keyframes fade-up {
from { opacity: 0; translate: 0 0.5rem; }
}
@keyframes wiggle {
0%, 100% { rotate: -3deg; }
50% { rotate: 3deg; }
}
}<li class="motion-safe:animate-fade-up">...</li>
<div popover class="transition-discrete opacity-100
starting:open:opacity-0 duration-200">...</div>References
- MDN: CSS cascade layers (opens in a new tab): how Tailwind's layers interact with your CSS
- MDN: @starting-style (opens in a new tab): what
starting:compiles to - Tailwind CSS docs (opens in a new tab): utilities and variants
- Tailwind: Theme variables (opens in a new tab): namespaces,
inline,static - Tailwind: Functions and directives (opens in a new tab):
@source,@utility,@variant - Tailwind: Upgrade guide (opens in a new tab): every v3 to v4 change
- Tailwind blog: v4.3 (opens in a new tab): scrollbars, logical utilities,
@variantlists - prettier-plugin-tailwindcss (opens in a new tab): class sorting
- tailwind-merge (opens in a new tab): conflict resolution
- cva (opens in a new tab) and tailwind-variants (opens in a new tab): variant APIs
- Bun: Full-stack dev server (opens in a new tab):
bun-plugin-tailwind