ehsan.blog
~/blog/how-to-configure-tailwind-v4-inline-with-theme — zsh
cat how-to-configure-tailwind-v4-inline-with-theme.md

How to configure Tailwind v4 inline with @theme (no config file)

·4 min read

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:

css
/* 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:

css
/* 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:

css
/* 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:

css
/* 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-lg auto-generate the matching utility classes.
  • Keep raw color values in :root and 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.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.