../

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.

SetupPackagesWire-up
Vitetailwindcss @tailwindcss/viteplugins: [tailwindcss()] in vite.config.ts
Next.js / PostCSStailwindcss @tailwindcss/postcss postcsspostcss.config.mjs
webpack (v4.2+)tailwindcss @tailwindcss/webpackloader after css-loader
CLItailwindcss @tailwindcss/clibunx @tailwindcss/cli -i in.css -o out.css --watch
Bun full-stacktailwindcss bun-plugin-tailwindbunfig.toml [serve.static] plugins
bun add tailwindcss @tailwindcss/postcss postcss   # Next.js
bun add -d tailwindcss @tailwindcss/vite           # Vite
postcss.config.mjs
const config = {
  plugins: { "@tailwindcss/postcss": {} },
};
export default config;
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
 
export default defineConfig({
  plugins: [react(), tailwindcss()],
});
bunfig.toml (Bun.serve with HTML imports)
[serve.static]
plugins = ["bun-plugin-tailwind"]
app/globals.css
@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

DirectiveDoes
@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().

NamespaceGeneratesExample
--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
--spacingthe 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.

sky-50 to sky-950 (default theme)
  • 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)
emerald-50 to emerald-950 (default theme)
  • 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:

generated.html
<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>
Result

Layout utilities

Flexbox, grid and position utilities have their own sections below, with live demos.

AreaUtilities
Displayblock, inline-block, flex, inline-flex, grid, contents, hidden, sr-only, flow-root
Overflowoverflow-hidden, overflow-x-auto, overflow-clip, overscroll-contain
Containercontainer (width: 100% plus a max-width per breakpoint; center with mx-auto)
Boxbox-border, aspect-video, aspect-square, aspect-3/2, object-cover, columns-3
Scrollsnap-x, snap-mandatory, snap-center, scroll-mt-16, scroll-smooth, scrollbar-thin, scrollbar-gutter-stable (v4.3)
Spacing & sizingNote
p-4, px-2, py-1, ps-3, pe-3any integer works: p-13 is calc(var(--spacing) * 13)
pbs-4, pbe-4, mbs-2, mbe-2logical block start and end (v4.2)
m-auto, mx-auto, -mt-2a leading - negates
w-64, w-1/2, w-full, w-screen, w-fitfractions are percentages
size-10width and height together
h-dvh, min-h-svh, h-lvhdynamic viewport units
max-w-prose, max-w-md, max-w-screen-lgmax-w-md comes from --container-md
inline-full, block-64, max-inline-lglogical sizing (v4.2)

Style utilities

TypographyNote
text-sm, text-base, text-xl/7size, with an optional / line height
font-sans, font-mono, font-medium, font-boldfamily and weight
leading-tight, tracking-wideline height, letter spacing
text-balance, text-pretty, text-wrap, text-nowraptext-wrap values
truncate, line-clamp-3, text-ellipsisoverflow
wrap-break-word, wrap-anywhere, break-alllong words and URLs
tabular-nums, font-features-["ss01"]numerals; font-features-* since v4.2
underline, decoration-wavy, underline-offset-4decoration
text-shadow-sm, text-shadow-lg, text-shadow-black/50text shadows (v4.1)
uppercase, italic, antialiased, whitespace-pre-wrap, tab-4misc; tab-* since v4.3
ColorNote
bg-sky-500, text-gray-950, border-red-200palette 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-500gradients: bg-linear-*, bg-radial, bg-conic
bg-linear-45, bg-linear-to-r/oklchgradient angle and interpolation space
fill-current, stroke-2, caret-pink-500, accent-brand-500SVG and form parts
scheme-dark, scheme-light-darkcolor-scheme
scrollbar-thumb-sky-700, scrollbar-track-sky-100scrollbar colors (v4.3)
Borders & effectsNote
border, border-2, border-t, border-bswidth (default color is currentColor)
rounded-xs … rounded-4xl, rounded-full, rounded-s-lgradius
ring-2 ring-brand-500 ring-offset-2, inset-ringbox-shadow rings (default width is 1px)
outline-2 outline-offset-2, outline-hiddenoutlines; outline-hidden keeps one in forced-colors mode
shadow-xs … shadow-2xl, inset-shadow-sm, shadow-brand-500/20shadows, colored shadows
opacity-50, mix-blend-multiply, blur-sm, backdrop-blur-mdfilters
drop-shadow-lg, drop-shadow-red-500/50drop 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/2individual transform properties
rotate-x-12, perspective-near, transform-3d, backface-hidden3D 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.

display: flex; flex-direction: row 1 2 3 free space main-start main-end main axis: justify-content cross-start cross-end cross axis: align-items flex-direction: column swaps the two axes
justify-* works along the main axis, items-* across it; flex-col swaps them
UtilityCSS
flex, inline-flexdisplay: flex / inline-flex
flex-row, flex-row-reverse, flex-col, flex-col-reverseflex-direction
flex-wrap, flex-nowrap, flex-wrap-reverseflex-wrap
justify-start, -center, -end, -between, -around, -evenly, -stretchjustify-content (main axis)
justify-center-safe, justify-end-safesafe center: never pushes overflow off the start edge (v4.1)
items-start, -center, -end, -stretch, -baseline, -baseline-lastalign-items (cross axis; default stretch)
content-start, content-between, …align-content: spacing between wrapped lines
self-auto, self-start, self-center, self-end, self-stretchalign-self on one item
gap-4, gap-x-6, gap-y-2gap between items (not at the edges)
flex-1flex: 1: share free space, ignoring content size
flex-autoflex: auto: grow and shrink from the content size
flex-initialflex: 0 auto: shrink but never grow (the default)
flex-noneflex: none: rigid
grow, grow-0, grow-3, shrink, shrink-0flex-grow, flex-shrink
basis-64, basis-1/3, basis-full, basis-(--w)flex-basis
order-1, order-first, order-last, -order-1order (visual only; tab order stays the DOM order)
ms-auto / ml-autopushes the item and everything after it to the end
min-w-0lets an item shrink below its content so truncate works
justify.html
<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>
Result

items-* aligns items of different heights across the row; baseline lines up the text:

items.html
<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>
Result

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.

sizing.html
<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>
Result

flex-wrap lets items break onto new lines; gap spaces both directions:

wrap.html
<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>
Result
Navbar and media object
<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.

1 -5 2 -4 3 -3 4 -2 5 -1 line from end col-start-2 col-span-2 col-span-full (1 / -1) col-start-3 -col-end-1 grid grid-cols-4: items start and end on line numbers; span-N counts tracks
Placement: items start and end on grid lines; span-N counts tracks
UtilityCSS
grid, inline-griddisplay: grid
grid-cols-3grid-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-subgridreuse the parent grid's tracks
col-span-2, col-span-fullgrid-column: span 2 / span 2, 1 / -1
col-start-2, col-end-4, -col-end-1start and end lines (-1 is the last line)
col-[2/4], col-2the grid-column shorthand
row-span-2, row-start-1, row-end-3the same for rows
grid-flow-row, grid-flow-col, grid-flow-densegrid-auto-flow; dense backfills holes
auto-rows-fr, auto-rows-[minmax(4rem,auto)], auto-cols-minsize of implicit tracks
gap-4, gap-x-6, gap-y-2gutters between tracks
justify-items-*, items-*, place-items-centeralign every item inside its cell
justify-self-*, self-*, place-self-centeralign one item
justify-*, content-*, place-content-centeralign the whole grid inside the container
[grid-template-areas:'a_b'], [grid-area:a]named areas: arbitrary properties (no built-in utility)
placement.html
<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>
Result

Drag the bottom-right corner of the box: auto-fit columns reflow without any breakpoint.

auto-fit.html
<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>
Result

grid-flow-dense moves later small items into holes left by wide ones (so the visual order no longer matches the DOM):

dense.html
<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>
Result

place-items-* aligns every item in its cell; place-self-* overrides one:

place-items.html
<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>
Result

With grid-rows-subgrid, each card's title, body and footer line up with its neighbors', however long the text is:

subgrid.html
<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>
Result

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:

holy-grail.html
<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>
Result

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.

absolute inset-0 covers the parent: top, right, bottom, left 0 relative parent top-2 left-2 top-2 right-2 -translate-1/2 top-1/2 left-1/2 inset-x-0 bottom-0 absolute children measure from the nearest positioned ancestor (relative)
Offsets measure from the nearest positioned ancestor
UtilityCSSOffsets from
staticposition: staticignores offsets and z-*
relativeposition: relativeits own place (space kept); anchors absolute children
absoluteposition: absolutethe nearest non-static ancestor
fixedposition: fixedthe viewport (or an ancestor with a transform or filter)
stickyposition: stickythe scrolling ancestor, once scrolled past top-*
Offset utilityCSS
inset-0inset: 0 (all four sides)
inset-x-0, inset-y-0inset-inline: 0, inset-block: 0
top-0, right-4, bottom-2, left-1/2one side; fractions are percentages
-top-2, -right-2negative: pokes out of the parent (badges)
top-full, left-full100%: just past the edge (menus, tooltips)
inset-s-0, inset-e-4inset-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/2center on the offset point
z-10, z-50, z-auto, -z-10, z-[5]z-index (needs a position other than static)
isolateisolation: isolate: a new stacking context without a z-index
offsets.html
<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>
Result

sticky top-0 headers stick to the top of their scrolling box, each until its own section scrolls away. Scroll inside the list:

sticky.html
<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>
Result

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:

stacking.html
<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>
Result

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:

dropdown.html
<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>
Result
Modal overlay, badge, sticky header
<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.

UtilityNote
transitioncolors, opacity, shadow, transforms, filters; 150ms with ease-in-out by default
transition-colors, transition-opacity, transition-transform, transition-all, transition-noneproperty sets
transition-[height,opacity]arbitrary list
transition-discretetransition-behavior: allow-discrete, for display and hidden
duration-75 … duration-1000, duration-[2s]transition-duration
delay-150, delay-300transition-delay
ease-linear, ease-in, ease-out, ease-in-out, ease-(--my-ease)timing function (from --ease-*)
animate-spin, animate-ping, animate-pulse, animate-bouncebuilt-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-nonereduced-motion gates
will-change-transformlayer promotion

Hover the box. The same move with five durations, then the four easings at duration-1000:

duration-ease.html
<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>
Result
progress ↑ (default for transition, duration-*: ease-in-out, 150ms) ease-linear (0,0,1,1) ease-in (.4,0,1,1) ease-out (0,0,.2,1) ease-in-out (.4,0,.2,1) x: time, y: progress; steep means fast. Values are Tailwind's --ease-* tokens
Tailwind's --ease-* tokens: x is time, y is progress

The four built-in animations, live:

built-in.html
<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>
Result

Custom animations go in @theme: the --animate-* variable creates the animate-* class, and its @keyframes sit inside the same block:

custom-keyframes.html
<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>
Result

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.

starting.html
<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>
Result
Reduced motionPattern
Only animate when allowedmotion-safe:animate-bounce, motion-safe:transition
Switch off for people who askedmotion-reduce:animate-none, motion-reduce:transition-none
Swap movement for a fadetransition motion-safe:hover:-translate-y-1 hover:opacity-90

Variants

Variants stack left to right, like selectors read: dark:md:hover:bg-x.

VariantApplies 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-4arbitrary selector variant

Responsive and container queries

VariantMeans
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/cardmake a container (inline-size); named
@container-sizea 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

StrategySetup
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

SyntaxExample
utility-[value]top-[117px], grid-cols-[1fr_2fr] (_ is a space)
utility-(--var)bg-(--brand) is var(--brand) (v3's bg-[--brand])
type hinttext-(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
! suffixflex! for !important (v3 used a ! prefix)
- prefix-mt-4, -translate-x-1/2 negatives
calcw-[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 branch

The tool (it needs Node 20+ installed) moves the JS config into CSS, renames classes in templates and swaps the PostCSS plugin.

v3v4
@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 tailwindcssnpx @tailwindcss/cli
shadow-sm / shadowshadow-xs / shadow-sm
rounded-sm / roundedrounded-xs / rounded-sm
blur-sm / blur, drop-shadow-sm / drop-shadowblur-xs / blur-sm, drop-shadow-xs / drop-shadow-sm
outline-noneoutline-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-growshrink-0, grow
overflow-ellipsistext-ellipsis
bg-gradient-to-rbg-linear-to-r
!flexflex!
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 JSgetComputedStyle(...).getPropertyValue("--color-x")
transform-nonescale-none, rotate-none, translate-none
start-0 / end-0inset-s-0 / inset-e-0 (deprecated in v4.2)
Behavior changeFix
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: defaultadd cursor-pointer or a base rule
Placeholder is the text color at 50%set placeholder:text-gray-400
The hidden attribute beats display utilitiesremove hidden to show the element
No Sass, Less or StylusTailwind handles imports, nesting and vendor prefixes
@apply in CSS modules or Vue/Svelte stylesadd @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-merge
lib/cn.ts
import { 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" });
LibraryPick 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-mergead-hoc conditional classes and className overrides

Tooling & performance

bun add -d prettier prettier-plugin-tailwindcss
.prettierrc
{
  "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.

TopicNote
EngineRust (Oxide) plus Lightning CSS: full builds take milliseconds, incremental ones microseconds
Outputonly the classes found in sources; most projects ship under 10 kB gzipped
Cascade layerstheme, base, components, utilities: unlayered CSS beats all of them
Variablestheme tokens are real CSS variables, so you can read them in JS or theme at runtime
Detectionscans text files, not an AST: keep class names whole and literal
Monoreposadd @source "../../packages/ui" for components that live outside the app
Editorthe Tailwind CSS IntelliSense extension reads your CSS entry file
Next.js styles setup
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-tailwindcss

Recipes

Design tokens in @theme

Semantic tokens that switch with the theme; utilities stay the same in both modes.

app/globals.css
@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.

lib/theme.ts
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().

components/ui/button.tsx
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