CSS-first config in globals.css
Tailwind v4's CSS-first config: define your design system in app/globals.css, not a JavaScript config file.
Last lesson you learned to read any class string and trust the scale underneath it: p-4 resolves through var(--spacing-4), and bg-card asks the design system which color cards get. You consumed a scale someone else defined. This lesson hands you the keys.
Picture the work on a real product. The brand has a signature blue, and you want bg-brand to mean it everywhere. Cards sit 1.75rem apart often enough that the gap deserves a name instead of [1.75rem] scattered around. A carousel needs a scroll-snap pattern no single utility covers. And one card must lay itself out by how much room it has, wide as a main column, stacked in a sidebar, whatever the screen. Four needs: a custom color, a named spacing step, a brand-new utility, and layout that responds to a component’s own box. In v4, every one is answered in CSS.
The Tailwind the rest of the web learned put all four in a JavaScript file, tailwind.config.ts, under a theme.extend object whose keys mapped to utilities through machinery you couldn’t see. In v4 they live in one file, app/globals.css, written in CSS. A new 2026 project has no tailwind.config.ts, but you’ll meet one in older codebases. By the end of this lesson you’ll author the scale you consumed last lesson, predict which utilities any token mints, and recognize the directives that make up a v4 stylesheet.
The config lives in CSS
Section titled “The config lives in CSS”In v4 you configure Tailwind in CSS. app/globals.css is your design-system control panel, the single file that answers what --spacing-4 is and what color bg-card produces, in CSS rather than a JavaScript object.
A few facts about the v4 baseline. The engine underneath is Lightning CSS , fast and with no separate PostCSS build step. The default palette is authored in OKLCH . Container queries are first-class. And the JavaScript config is optional, absent in a fresh project.
Everything starts from the first line of globals.css, the import that turns the framework on:
@import "tailwindcss";Importing tailwindcss brings in three things. First, every utility class, the p-4, bg-card, flex surface from last lesson. Second, the default theme: the spacing scale, colors, and radii you consumed without defining. Third, a base reset called Preflight that smooths out browser defaults before your styles land.
This is one file, imported once. The scaffold already wires it into every page from the top of app/layout.tsx:
import './globals.css';That’s a side-effecting import: it pulls in no value, just telling the bundler to include the stylesheet. (Side-effecting imports go first in the import order, a convention enforced across the codebase.) So you aren’t building a pipeline; you’re filling in a file that already runs on every page.
Here’s the shape of that file, the skeleton the rest of this lesson populates:
@import "tailwindcss";
@theme { /* design tokens → utilities (bg-brand, p-gutter, …) */}
@utility scroll-snap-x { /* a custom utility, for a pattern no built-in covers */}
@custom-variant pointer-coarse (@media (pointer: coarse));Your own tokens don’t replace the defaults. @theme extends the default theme, so your spacing steps sit alongside Tailwind’s and your colors alongside the defaults. You only lose a default by explicitly clearing it, a deliberate move covered at the end of the next section. Until then, everything you add is additive.
@theme: tokens that mint their own utilities
Section titled “@theme: tokens that mint their own utilities”This @theme block defines three tokens from the opening, a brand color, a gutter spacing step, and a card radius, with the JSX that consumes them:
@theme { --color-brand: oklch(0.62 0.19 256); --spacing-gutter: 1.75rem; --radius-card: 0.75rem;}<article className="flex flex-col gap-gutter bg-brand p-gutter rounded-card"> <h3 className="text-brand">{/* ... */}</h3></article>Notice what you did not do. You never registered bg-brand or listed p-gutter, gap-gutter, or rounded-card in a config. You wrote three CSS variables, and each one snapped a whole family of utilities into existence: the single --color-brand line produced bg-brand, text-brand, border-brand, and ring-brand. That is the load-bearing idea of v4’s design system: a @theme token is a CSS variable, and its name deterministically mints a family of utilities.
So everything comes down to the name, and every token follows one pattern:
--{namespace}-{name}The namespace is the part Tailwind recognizes; it decides which utility family the token joins. The name is the part you choose, and it’s what you type after the family’s prefix. Read --color-brand as namespace color, name brand: color routes it to the color utilities, and brand shows up in bg-brand, text-brand, border-brand. Rename it --color-accent and you get bg-accent instead. The name travels from token to utility unchanged.
Watch one token become a family:
The mapping runs both directions, and the reverse direction is where the most common beginner bug lives, so the namespaces are worth knowing. You won’t define most of them on day one, but you need to read a token and predict its family, and read a missing utility and spot the wrong namespace.
Read it forward to predict the utilities a token mints, or backward to debug a missing one. The tinted rows are bridges: --breakpoint-* back to last lesson’s prefixes, --container-* forward to the next section.
A few rows catch people out. --font-* is font family only: weight comes from --font-weight-*, letter-spacing from --tracking-*, line-height from --leading-*, each its own namespace. --text-* is font size, and a size token can carry a paired line-height with a double-dash suffix: --text-tag for the size, --text-tag--line-height for the line-height that ships with it. And --breakpoint-* is where last lesson’s sm:, md:, lg: prefixes come from, so you add a breakpoint the same way you add a color: define --breakpoint-tablet: 56rem and tablet: becomes a responsive prefix.
These tokens hold literal values, a real oklch(...), a real 1.75rem, which is the right form when the token is the value. A second form, @theme inline { … }, points a token at another variable instead; you’ll meet it later in this chapter, when dark mode needs tokens that swap.
When the utility doesn’t exist
Section titled “When the utility doesn’t exist”Eventually you’ll define a token, reach for its utility, and find it missing. Usually the namespace prefix is wrong. You wrote what felt natural:
--brand-color: oklch(0.62 0.19 256);--color-brand: oklch(0.62 0.19 256);bg-brand came back undefined because brand isn’t a namespace Tailwind recognizes, so there’s no --brand-* family. Lead with a real namespace, --color-brand: same name, same value, only the order changed, and the order is what Tailwind reads to pick the family.
So make this your first question for a missing utility, the way “is the class in the DOM?” was last lesson’s: does the token start with a real namespace? Run its prefix against the table above. If bg-brand doesn’t exist, the token is probably --brand-… instead of --color-….
The token alone isn’t enough, though. Defining --color-brand makes bg-brand available, but it only lands in your output CSS if the scanner sees the string bg-brand in your source, last lesson’s text-scan rule again: the token is permission, the class string in your JSX is what makes it real. One note: @theme tokens resolve globally, available on every element, so you never import or scope them.
Now drive the mapping backwards. Each token below has its namespace blanked, and a comment naming the utilities the line must produce. Fill in the namespace that mints them.
Each token's namespace prefix is blanked. The comment names the utilities that line must produce. Pick the namespace that mints them. Pick the right option from each dropdown, then press Check.
@theme { ___-brand: oklch(0.62 0.19 256); /* must produce bg-brand, text-brand */ ___-gutter: 1.75rem; /* must produce p-gutter, gap-gutter */ ___-card: 0.75rem; /* must produce rounded-card */}Narrowing the palette on purpose
Section titled “Narrowing the palette on purpose”One deliberate move shows up in design-system-strict codebases. By default your tokens sit alongside Tailwind’s, so typing bg- offers your bg-brand plus bg-red-500, bg-slate-700, and the rest of the default palette. For a team that wants the design system to be the only source of colors, that’s noise. Setting a namespace to initial clears its defaults, leaving only what you define:
@theme { --color-*: initial;
--color-background: oklch(1 0 0); --color-foreground: oklch(0.15 0 0); --color-brand: oklch(0.62 0.19 256);}The --color-*: initial line wipes Tailwind’s default color tokens, and the three lines under it rebuild the palette from scratch. Now bg-red-500 doesn’t exist and IntelliSense only suggests the project’s colors. Treat this as an opt-in discipline: it commits you to a closed palette, the right call only when the team wants one. A broader --*: initial clears every default namespace at once, which is rarely what you want.
@utility and @custom-variant: authoring new utilities and variants
Section titled “@utility and @custom-variant: authoring new utilities and variants”@theme is the directive you reach for daily, but it has one hard limit: it defines values for utility families that already exist, a new color for bg-*, a new step for p-*. It can’t invent a utility for a CSS property Tailwind doesn’t expose, and it can’t invent a new variant prefix. Those two gaps are what @utility and @custom-variant fill.
@utility writes a brand-new utility in CSS. Reach for it when a visual pattern spans more than one declaration, no built-in covers it, and it repeats across components. It’s last lesson’s instinct one level up: a recurring arbitrary value means the scale should grow, a recurring cluster of arbitrary properties means you want a named utility. A horizontal scroll-snap container is the classic case, a handful of scroll declarations that always travel together:
@utility scroll-snap-x { scroll-snap-type: x mandatory; overscroll-behavior-x: contain; scroll-padding-inline: 1rem;}Now scroll-snap-x is a real utility you can drop on any carousel. Authored as one, it composes with everything else and respects variants, so md:scroll-snap-x works for free. There’s also a functional form, @utility tab-* { tab-size: --value(integer); }, that generates a whole family (tab-2, tab-4) from one declaration; recognize the shape, you won’t author one today.
This is why last lesson steered you away from @apply. @apply folds utilities back into a named component class, reintroducing the indirection you left behind. @utility names the same pattern without that cost: it stays in the utility layer, composes, and takes variants.
@custom-variant writes a brand-new variant prefix in CSS. Reach for it when you need a variant: gate the built-ins don’t offer. The tabs below contrast the two directives: a static @utility, then the two shapes @custom-variant takes.
@utility scroll-snap-x { scroll-snap-type: x mandatory; overscroll-behavior-x: contain;}<ul className="flex overflow-x-auto scroll-snap-x">{/* ... */}</ul>A cross-cutting visual pattern no single built-in covers, repeated across components. It stays in the utility layer, so it composes and takes variants, and md:scroll-snap-x works for free.
@custom-variant pointer-coarse (@media (pointer: coarse));<button className="p-2 pointer-coarse:p-4">{/* ... */}</button>A new gate built on a media or feature query the built-ins don’t ship. Here it bumps touch targets up on coarse (finger) pointers. Used like any variant: pointer-coarse:utility.
@custom-variant theme-blue (&:where([data-theme=blue] *));@custom-variant dark (&:where(.dark, .dark *));<div className="text-foreground theme-blue:text-sky-600">{/* ... */}</div>A gate keyed on a DOM attribute or class an ancestor carries. The second line is the exact dark-mode gate you’ll wire up later in this chapter; dark: is itself a custom variant. The :where(...) wrap keeps specificity neutral, and why that matters is the next chapter.
Remember that last tab. The dark: variant you’ll use for theming later in this chapter isn’t built into Tailwind; it’s a @custom-variant like any other:
@custom-variant dark (&:where(.dark, .dark *));That one line is what makes dark:bg-background mean “when a .dark ancestor is present.” For now, hold the shape: a custom variant is a named selector wrapper, and dark is the one you’ll lean on most.
You’ve now seen four directives. Sort them by the job only each can do, since that’s what tells you which to reach for.
Match each directive to the job that only it can do. Click an item on the left, then its match on the right. Press Check when done.
@import "tailwindcss"@theme@utility@custom-variant@container: layout that reads the component’s width
Section titled “@container: layout that reads the component’s width”Last lesson’s md: and lg: prefixes gate on the viewport, the browser window’s width. That’s right for page-level layout, but it can’t see where reusable components actually live: their own box.
Consider one card. In the main content area it owns the full column, so its contents should sit side by side. Drop that same card into a narrow sidebar and it should stack. The viewport is identical in both spots, so a md: prefix styles both alike, wrong for at least one. The card shouldn’t ask how wide the screen is; it should ask how wide its own box is.
That question is a container query , and v4 makes it first-class in two steps. Mark an ancestor as a container with the @container utility. Then gate utilities with @-prefixed variants that read that container’s width instead of the viewport’s:
Same component, same screen width — only the available box differs. A viewport breakpoint reads the screen, so it would style both cards identically; a container query reads each card’s own width, so the sidebar one collapses by itself.
In classes, that card looks like this, with @container on the wrapper and @-variants on the element that adapts:
<div className="@container"> <article className="grid grid-cols-1 gap-4 @lg:grid-cols-2"> {/* thumbnail + body */} </article></div>The <article> is one column by default, grid-cols-1, the narrow-sidebar case. @lg:grid-cols-2 reads “when this container reaches the @lg width, use two columns.” It’s the same mint-a-utility model: those @sm/@lg breakpoints come from the --container-* namespace in the table above, producing container-query variants instead of viewport ones.
One mistake is common enough to name: an @-variant only works inside a @container ancestor. Forget to mark the container and @lg:grid-cols-2 silently does nothing, with nothing to measure.
The rest of the surface, for recognition
Section titled “The rest of the surface, for recognition”A few directives round out a v4 stylesheet. None is daily work, but you’ll meet them in real codebases, so recognize each and when it earns its place.
@source points the scanner at files it doesn’t auto-detect. Tailwind finds utilities by reading your source as text, and scans your app automatically. When a class string lives outside the default paths, such as a shared UI package in a monorepo , name that path so the scanner reads it too:
@source "../packages/ui/src/**/*.tsx";Without it, utilities used only inside that package never get generated.
@plugin loads an official Tailwind plugin from CSS:
@plugin "@tailwindcss/typography";@plugin "@tailwindcss/forms";Typography ships a prose class that styles rendered Markdown; forms ships sensible defaults for form elements. This is the loading mechanism; each plugin’s own surface is a later topic.
@config loads a legacy JavaScript tailwind.config.ts, the bridge for a project mid-migration:
@config "../tailwind.config.ts";This course’s projects are CSS-first from line one and never use it, but you’ll see it in the wild.
Everything in this lesson, your directives in globals.css plus the source files the scanner reads, feeds one engine that emits one stylesheet:
The output is built from your source, not your config. Unused tokens and utilities cost nothing —
they were never written out — and a class name assembled from a string
(`bg-${color}-500`)
never appears, because the scanner never read it as text.
This is why two things from last lesson hold. Unused tokens cost nothing: define a hundred colors, use three, and only three ship. And a dynamically-built class name (`bg-${color}-500`) never makes it out, because Lightning CSS emits only what the scanner read as literal text, and it never read the assembled string. There’s no separate build command, since Turbopack drives Lightning CSS through the normal dev and build process.
Two things to carry out of this lesson.
Configuration moved into CSS. It lives in app/globals.css as CSS directives, not a JavaScript tailwind.config.ts, and a new 2026 project has no config file. @import "tailwindcss" is line 1, bringing in the utility classes, the default theme, and Preflight.
A @theme token’s namespace deterministically mints a utility family. --color-brand mints bg-brand, text-brand, border-brand, and ring-brand; --spacing-gutter mints p-gutter, m-gutter, and gap-gutter. The namespace decides the family; your name is the name in every utility. When an expected utility doesn’t exist, ask first whether the token started with a real namespace: the canonical bug is --brand-color where you meant --color-brand.
You can now define tokens and consume them. Next you’ll compose class strings safely, so a className override wins without two paddings competing: the cn() helper.
External resources
Section titled “External resources”The canonical namespace to utility-family reference: every namespace and the utilities it mints, the lookup behind this lesson's table.
The full reference for @import, @theme, @utility, @custom-variant, @source, @plugin, and @config.
Ahmad Shadeed's hands-on explainer: resize live containers to feel why a component should query its own box, the CSS under the @container section here.
The CSS-first model in the team's own words: why the config moved out of JavaScript and into globals.css.