How to configure Tailwind v4 inline with @theme (no config file)
If you’ve used Tailwind v3, you know the ritual: a tailwind.config.js file
full of JavaScript objects defining your colors, spacing, and fonts. Tailwind
v4 flips that. Configuration now lives in CSS itself, and my portfolio has no
tailwind.config file anywhere — the entire theme is declared at the top of
src/index.css. Here’s how that works, line by line from the real file.
Step 1: import Tailwind from CSS
There’s no PostCSS plugin list or content-scanning config to write. You just import Tailwind:
/* src/index.css */
@import "tailwindcss";
@import "tw-animate-css";The first line pulls in all of Tailwind — its base reset, utilities, and the
machinery that reads your theme. The second is an animation utility add-on.
Combined with the @tailwindcss/vite plugin in vite.config.ts, that single
@import is the whole setup. No config file needed.
Step 2: define a custom dark-mode variant
Tailwind’s dark: prefix needs to know how you signal dark mode. In v4 you
declare that in CSS with @custom-variant:
/* src/index.css */
@custom-variant dark (&:is(.dark *));This says “the dark: variant applies when the element is inside something with
the class dark.” So <html class="dark"> turns on every dark: utility down
the tree. The &:is(.dark *) selector is just “me, when I’m a descendant of
.dark.” (In v3 this was the darkMode: 'class' config option — same idea,
now expressed in CSS.)
Step 3: map your design tokens with @theme
This is the heart of it. @theme is where you tell Tailwind about your custom
values so they become real utility classes. The inline keyword means the
values are used as written rather than wrapped in extra layers. Here’s a trimmed
piece of my block:
/* src/index.css */
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
--color-ring: var(--ring);
--font-mono: "JetBrains Mono Variable", "JetBrains Mono", monospace;
}The naming is a convention Tailwind understands. A variable named
--color-primary automatically generates bg-primary, text-primary,
border-primary, and friends. --font-mono gives you font-mono. The
--radius-* names feed rounded-sm, rounded-md, and so on. So just by
declaring these variables, the utility classes spring into existence — that’s
what replaces the old JS config’s theme.extend block.
Notice each color points at another variable (var(--background)), not a
literal color. That’s an intentional two-layer split, which brings us to the
raw values.
Step 4: the raw values live in :root
The actual colors are defined once in :root (the top of the document), and
@theme just references them:
/* src/index.css */
:root {
--radius: 0.25rem;
--background: oklch(0.05 0 0);
--foreground: oklch(0.92 0 0);
--primary: oklch(0.92 0 0);
--primary-foreground: oklch(0.05 0 0);
--muted: oklch(0.13 0 0);
--muted-foreground: oklch(0.5 0 0);
--border: oklch(0.2 0 0);
--ring: oklch(0.5 0 0);
--destructive: oklch(0.577 0.245 27.325);
/* ...and the rest */
}These are all in OKLCH, a modern color format written as
oklch(lightness chroma hue). The first number is lightness from 0 (black) to 1
(white). Most of this palette has chroma and hue of 0 — pure grays — which is
why the site reads as a monochrome CRT terminal. oklch(0.05 0 0) is a near-black
background; oklch(0.92 0 0) is a soft near-white for text. OKLCH is nice
because lightness is perceptually even: bumping the first number brightens the
color predictably, which makes building a consistent gray scale trivial.
Why the two-layer split matters
At first the indirection looks redundant — why not put the OKLCH values directly
in @theme? Because the split separates two concerns:
graph LR A[:root raw values<br/>--primary: oklch] --> B["@theme tokens<br/>--color-primary: var(--primary)"] B --> C[Utility classes<br/>bg-primary, text-primary]
:root is your single source of truth for what the colors are. @theme maps
those to what Tailwind exposes. To retheme the site you edit only the :root
values, and every utility class updates automatically. It’s also how shadcn/ui
components (which read --background, --primary, etc. directly) and Tailwind
utilities can share the exact same palette.
The payoff
No tailwind.config.js. No JavaScript to define design tokens. Open one CSS
file and you can see and change the entire theme — colors, radii, fonts — in
plain CSS variables the browser understands natively. For a small project that’s
a real reduction in moving parts. I use the same config-free approach on my blog —
see styling an Astro blog with Tailwind v4.
What to remember
- Tailwind v4 configures from CSS:
@import "tailwindcss"replaces the config file and PostCSS setup. @custom-variant dark (&:is(.dark *))defines class-based dark mode in CSS.- Inside
@theme inline, variable names like--color-primary,--font-mono, and--radius-lgauto-generate the matching utility classes. - Keep raw color values in
:rootand reference them from@theme— one source of truth, easy to retheme. - OKLCH (
oklch(L C H)) gives perceptually even lightness, perfect for a consistent grayscale palette.