CSS-first config in globals.css
Tailwind v4's CSS-first configuration, where the design system you consume in JSX is defined entirely in app/globals.css instead of a JavaScript config file.
Last lesson you learned to read any class string on a JSX tag and to trust the scale underneath it. You wrote p-4 knowing it resolves through var(--spacing-4), and bg-card knowing it asks the design system which color cards get. You were a consumer of a scale someone else defined. This lesson hands you the keys to that scale.
Picture the work in front of you on a real product. The brand has a signature color, call it the brand blue, and you want bg-brand to mean it everywhere. The design uses a specific gutter between cards, 1.75rem, often enough that it deserves a name instead of the [1.75rem] you’d otherwise scatter around. A horizontal carousel needs a scroll-snap pattern that no single utility covers. And one card needs to lay itself out based on how much room it has: wide when it’s the main column, stacked when it’s squeezed into a sidebar, regardless of the screen size. That’s four needs in all: 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 of them is answered in CSS.
In the Tailwind the rest of the web learned, all four lived in a JavaScript file called tailwind.config.ts, in a theme.extend object whose keys mapped to utilities through machinery you couldn’t see. In v4, all four live in one file, app/globals.css, written in CSS. There is no tailwind.config.ts in a new 2026 project. You’ll still meet one in older codebases, so it helps to recognize it, but the v4 default, and this course’s default, is to configure Tailwind in CSS. By the end of this lesson you’ll author the token scale you consumed last lesson, predict exactly which utilities any token you write brings into existence, and recognize the full set of directives that make up a v4 stylesheet.
The config moved into CSS
Section titled “The config moved into CSS”Before any directive, it helps to settle where everything lives, because every piece that follows needs a place to land. The shift is one sentence: v4 configures Tailwind in CSS. app/globals.css is your project’s design-system control panel, the single file that answers what --spacing-4 is and what color bg-card produces. It answers in CSS, not in a JavaScript object.
Here are a few facts about the 2026 v4 baseline, worth naming so you recognize them later. The engine underneath is Lightning CSS , which is fast and does the work without a separate PostCSS build step bolted on. The default color palette is authored in OKLCH ; the reason for that color space is a later chapter, so for now just know the defaults aren’t hex. Container queries are first-class. And the JavaScript config is optional, absent entirely in a fresh project.
Everything starts from a single line. The first line of globals.css is the import that turns the framework on:
@import "tailwindcss";That one line collapses three separate directives older projects used into a single import: @tailwind base;, @tailwind components;, and @tailwind utilities;. 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, the default colors, and the radii, the tokens you consumed without defining. Third, a base reset called Preflight that smooths out browser defaults before your styles land. Preflight’s actual rules are a next-chapter topic; for now, “the import brings in a reset” is enough.
This is one file, imported once. In the project scaffold, the top of app/layout.tsx already carries the line that wires it in:
import './globals.css';That’s a side-effecting import: it pulls in no value, it just tells the bundler to include this stylesheet. (Side-effecting imports go first in the import order, a convention you’ll see enforced across the codebase.) The point worth holding is that you are editing a file already imported and already wired into every page. You’re not building a pipeline; you’re filling in a file that already runs.
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));One reassurance before you start adding to it: your own tokens do not throw the defaults away. @theme extends the default theme, so your spacing steps sit alongside Tailwind’s and your colors alongside the defaults. You only lose a default if you explicitly clear it, which is a deliberate move we’ll get to at the end of the next section. Until then, assume everything you add is additive.
@theme: tokens that mint their own utilities
Section titled “@theme: tokens that mint their own utilities”The @theme directive carries the idea the rest of this lesson builds on. Here is a @theme block defining three of the things from the opening, a brand color, a gutter spacing step, and a card radius, along 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>Look at what you did not do. You didn’t register bg-brand anywhere. You didn’t list p-gutter or gap-gutter or rounded-card in a config of utilities. You wrote three CSS variables, and a whole family of utilities snapped into existence for each one. 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. There’s no registration step and no opaque mapping. The variable name is the instruction.
So everything comes down to the name. Every token follows one pattern:
--{namespace}-{name}The namespace is the part Tailwind recognizes, and it decides which utility family the token joins. The name is the part you choose, and it’s exactly what you type after the family’s prefix in a utility. Read --color-brand as namespace color, name brand: the color namespace routes it to the color utilities, and brand is what shows up in bg-brand, text-brand, border-brand. Change the name to --color-accent and you’d get bg-accent instead. The name travels from the token into the utility unchanged.
The clearest way to see that travel is to watch one token become a family:
That mapping is deterministic in both directions, and the reverse direction is where the most common beginner bug lives, so it pays to learn the namespaces that matter. You won’t define most of these on day one, but you need to recognize, from a token, which family it feeds, and from a missing utility, which namespace you got wrong.
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 things in that table trip people up, so they’re worth pulling out. The --font-* namespace is font family only. It’s tempting to assume it covers everything about type, but font weight (font-bold) comes from --font-weight-*, letter-spacing from --tracking-*, and line-height from --leading-*, each its own namespace. The --text-* namespace is font size, and a size token can carry a paired line-height by appending a double-dash suffix: --text-tag for the size, --text-tag--line-height for the line-height that ships with it. And notice --breakpoint-*: those sm:, md:, lg: prefixes you used last lesson are theme tokens too. md is just an entry on the --breakpoint-* namespace, which means you can add your own breakpoint the same way you’d add a color. Define --breakpoint-tablet: 56rem and tablet: becomes a responsive prefix.
The values in these examples are written out literally: a real oklch(...), a real 1.75rem. That’s the right call when the token is the value. There’s a second form, @theme inline { … }, for when a token needs to point at another variable rather than hold a literal. You’ll meet it later in this chapter, when dark mode needs tokens that swap. The tokens here hold literals, so the plain @theme is the right form.
When the utility doesn’t exist
Section titled “When the utility doesn’t exist”Sooner or later you’ll define a token, reach for its utility, and find it doesn’t exist. Nine times out of ten the cause is the same: 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);Then bg-brand came back undefined, because brand isn’t a namespace Tailwind recognizes. There’s no --brand-* family of utilities. The fix is to lead with the namespace Tailwind does recognize: --color-brand. The name and the value are identical, and only the order changed. The order is what matters, because the prefix is the part Tailwind reads to decide which family to build.
So make this your first debugging question for a missing utility, the way “is the class in the DOM?” was last lesson’s first question for a missing style: does the token start with a real namespace? Run the token’s prefix against the table above. If bg-brand doesn’t exist, the token is almost certainly --brand-… instead of --color-….
There’s one more thing the token alone doesn’t guarantee. Defining --color-brand makes bg-brand available, but the utility still only lands in your output CSS if the scanner literally sees the string bg-brand somewhere in your source. That’s last lesson’s text-scan rule again: a token is permission for a utility to exist, and the class string in your JSX is what makes it real. One last note: @theme tokens resolve globally. They’re available on every element everywhere, which is why you never import or scope them. The mechanism behind that is a next-chapter topic; for now, just know a token, once defined, is in scope everywhere.
Now try driving the mapping the hard way, backwards. The block below defines three tokens, but the namespace on each has been blanked. Each line tells you, in a comment, which utilities it 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”Here’s one deliberate move worth knowing, because you’ll see it in design-system-strict codebases. By default your tokens sit alongside Tailwind’s, so when you type bg-, IntelliSense offers your bg-brand and also 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 whole namespace to initial clears the defaults for it, 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; the three lines under it rebuild the palette from scratch. Now bg-red-500 doesn’t exist and IntelliSense only ever suggests the project’s colors. Treat this as an opt-in discipline, not a default: it’s a commitment to a closed palette, and it’s the right call only when the team genuinely wants one. There’s also a --*: initial that clears every default namespace at once, which is rarely what you want.
@utility and @custom-variant: extending the surface
Section titled “@utility and @custom-variant: extending the surface”@theme is the daily directive, and for most styling it’s all you reach for. But it has a hard limit, and that limit is exactly where the next two directives come in.
@theme defines values that plug into utility families that already exist. It can give the bg-* family a new color, or the p-* family a new spacing step. What it can’t do is invent a brand-new utility for a CSS property Tailwind doesn’t already 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 is more than a single declaration, isn’t covered by any built-in utility, and repeats across components. The threshold is the same instinct from last lesson, where every arbitrary value is a signal the scale should grow. Apply it one level up: when you find yourself pasting the same cluster of arbitrary properties into three or more components, that cluster wants to be a named utility. A horizontal scroll-snap container is the classic example, 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. Because it’s authored as a utility, 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); }, which generates a whole family (tab-2, tab-4) from one declaration. Recognize the shape; you won’t author one today.
This is exactly the distinction last lesson drew around @apply. @apply folds utilities back into a named component class, which is the wrong move, because it brings the named-class indirection back in. @utility is the right way to name something reusable: it stays in the utility layer, it composes, and it takes variants. When a pattern wants a name, @utility gives it one without giving up anything utility-first bought you.
@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. It comes in two shapes:
@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.
That last tab is the one to remember, because it’s a forward bridge. The dark: variant you’ll use for theming later in this chapter is not built into Tailwind; it’s a @custom-variant like any other:
@custom-variant dark (&:where(.dark, .dark *));That single line is what makes dark:bg-background mean “when a .dark ancestor is present.” The :where(...) wrapper around the selector keeps its specificity neutral so it doesn’t override your other styles. Why specificity matters, and how the cascade resolves it, is the next chapter’s job. Here, just recognize 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. Before moving on, sort them by the job only each can do, since that’s what tells you which one 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 width of the browser window. That’s the right tool for page-level layout, but it has a blind spot, and that blind spot is exactly where reusable components live.
Consider one card. In the main content area it has the full column to itself, so you want its contents side by side. Drop that same card into a narrow sidebar and it should stack, since there’s no room for two columns. But the viewport hasn’t changed; the browser window is the same width in both places. A md: prefix sees the viewport and styles both cards identically, which is wrong for at least one of them. 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. The shape has two parts. First, mark an ancestor as a container with the @container utility, which is a class on the JSX tag like everything from last lesson. 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 thing 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, which is the narrow-sidebar case. It switches to two columns only once its container reaches @lg, since @lg:grid-cols-2 reads “when this container is at least the @lg width, use two columns.” This is the same mint-a-utility model you just learned: those @sm/@lg breakpoints come from the --container-* namespace in the table above, producing container-query variants instead of viewport ones. There are richer forms for when you need them. Named containers (@container/sidebar on the parent, @lg/sidebar:grid-cols-2 on the child) target a specific ancestor when they nest, and max-width queries (@max-md:hidden, “hidden below the container’s md”) gate the other direction. Recognize them; you don’t need to drill them here.
One setup mistake is common enough to name: an @-variant only does anything inside a @container ancestor. Forget to mark the container and @lg:grid-cols-2 silently does nothing, because there’s no container for it to measure.
The rest of the surface, for recognition
Section titled “The rest of the surface, for recognition”You’ve now learned every directive you’ll reach for regularly. A handful remain that round out a v4 stylesheet. You’ll meet them in real codebases, so it’s worth recognizing each one and knowing when it earns its place, even though none of them is daily work.
@source points the scanner at files it doesn’t auto-detect. Recall the text-scan rule: Tailwind finds utilities by reading your source as text. It scans your app automatically, but if a class string lives somewhere outside the default paths, such as a shared UI package in a monorepo, you tell the scanner to read it too:
@source "../packages/ui/src/**/*.tsx";This is the monorepo reach. Without it, utilities used only inside that external package would never be generated, because the scanner never looked there.
@plugin loads an official Tailwind plugin from CSS:
@plugin "@tailwindcss/typography";@plugin "@tailwindcss/forms";The typography plugin ships a prose class that styles rendered Markdown, exactly the long-form-content case last lesson pointed at. The forms plugin ships sensible defaults for form elements. You’re seeing the loading mechanism here; each plugin’s own surface is a later topic.
@config is the bridge to the old world. If a project is mid-migration and still has a JavaScript tailwind.config.ts, v4 can load it:
@config "../tailwind.config.ts";This is purely a recognition hook for legacy codebases. This course’s projects are CSS-first from line one and never use it, but you’ll meet it in the wild, and now you know what it’s doing.
And finally, the loop closes. Everything in this lesson, your directives in globals.css plus the source files the scanner reads, feeds one engine, which 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.
That picture is why two things from last lesson are true. Unused tokens cost nothing: define a hundred colors, use three, and only three ship; the rest were never written to the output. And a dynamically-built class name (`bg-${color}-500`) never makes it out the other side, because Lightning CSS only emits what the scanner read as literal text, and it never read the assembled string. The output is derived from your source, not your config. There’s no separate build command to run, since Turbopack drives Lightning CSS as part of the normal dev and build process.
The lesson reduces to one relocation and one rule.
The relocation: Tailwind’s configuration moved into CSS. It lives in app/globals.css, in CSS directives, not a JavaScript tailwind.config.ts, and there is no config file in a new 2026 project. @import "tailwindcss" is line 1, and it brings in three things: the utility classes, the default theme, and Preflight.
The rule, the one to carry out of this lesson: a @theme token is a CSS variable whose 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, and the name you choose is the name in every utility. When a utility you expected doesn’t exist, the first question is did the token start with a real namespace? The canonical bug is --brand-color where you meant --color-brand.
Around that core: @theme extends the defaults, and you clear one with --namespace-*: initial only when you want a closed palette. When a value isn’t enough, @utility adds a brand-new utility and @custom-variant adds a brand-new variant prefix, and @custom-variant dark (&:where(.dark, .dark *)) is the dark-mode gate you’ll wire up shortly. @container lets a component query its own box instead of the viewport. @source, @plugin, and @config round out the surface for monorepos, plugins, and legacy configs. And Lightning CSS compiles all of it into one stylesheet holding only the utilities your source actually used.
You can now both define the tokens and consume them. Next you’ll learn to compose class strings safely: what happens when a component accepts a className override and you need the override to win without two paddings competing. That’s the cn() helper, and it’s the last piece before the variants that read the DOM’s own state.
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.