Tailwind v4 + Vite from Scratch: CSS-First @theme (2026)

May 26, 2026

Tailwind v4 + Vite from Scratch: CSS-First @theme (2026)

TL;DR

In about thirty minutes you will scaffold a Vite + TypeScript project, install Tailwind CSS v4.3.0 via the first-party @tailwindcss/vite plugin, configure every design token in a single @theme block, wire a class-toggle dark mode with @custom-variant, force a few dynamic class names into the bundle with @source inline(), and ship a custom functional utility with @utility. No tailwind.config.js, no postcss.config.js, no autoprefixer. The CSS file is the config.

A 35-word answer for the search snippet: Tailwind CSS v4 with Vite needs two packages (tailwindcss and @tailwindcss/vite), one plugin line in vite.config.ts, and one @import "tailwindcss"; in your CSS. Every design token lives in @theme directly in that same CSS file.

What you will learn

  • How to install Tailwind CSS v4.3.0 with the @tailwindcss/vite plugin on a fresh create-vite@9 + TypeScript scaffold.
  • How to define every design token (colors, fonts, breakpoints, custom palette ramps) in a single CSS-first @theme block.
  • How to choose between @theme and @theme inline, and which output each produces.
  • How to wire class-toggle dark mode with @custom-variant, including persistence via localStorage.
  • How to define a custom functional utility with @utility and --value(integer).
  • How to control which files Tailwind scans with @source, including @source inline() for dynamic class names and @source not for exclusions.
  • How to use @reference so @apply keeps working inside CSS Modules and <style> blocks in Vue/Svelte/Astro single-file components.
  • The exact upgrade path off @tailwind base; @tailwind components; @tailwind utilities; (and why you should not write a darkMode: 'class' JS config anymore).

Prerequisites

  • Node.js 20.19+ on the 20.x line, or 22.12+ on anything newer. Vite 8's engines field requires ^20.19.0 || >=22.12.0, and Node 24 is the current Active LTS through October 20261. Tailwind v4's Oxide engine ships Rust-backed native binaries that need a modern Node.
  • A modern browser for the demo. Tailwind v4 targets Safari 16.4+, Chrome 111+, and Firefox 128+; older browsers should stay on Tailwind 3.42.
  • A scratch directory you can delete. Everything below lives in a single project folder.

Step 1 — Scaffold the Vite project

Create a fresh vanilla-ts Vite project. The vanilla-TypeScript template gives you the smallest possible surface area so the Tailwind wiring is the only moving part.

npm create vite@9.0.7 nlt-tailwind -- --template vanilla-ts
cd nlt-tailwind
npm install

create-vite@9.0.7 is the current major (released 2026-05-11) and ships templates for vanilla, vue, react, preact, lit, svelte, solid, and qwik — each in JavaScript and TypeScript flavors3. The vanilla-ts template generates index.html, src/main.ts, src/style.css, src/counter.ts, a src/assets/ folder with the Vite + TypeScript logos, tsconfig.json, and a minimal package.json.

Verify the scaffold runs:

npm run dev

You should see Vite 8 boot the dev server on http://localhost:5173 and serve the default Vite + TypeScript starter page. Stop the dev server with Ctrl+C.

Step 2 — Install Tailwind v4 and the Vite plugin

Two packages — that is the whole install.

npm install -D tailwindcss@4.3.0 @tailwindcss/vite@4.3.0

Install the exact patch version. Tailwind v4 has shipped many minor and patch releases on the 4.1, 4.2, and 4.3 lines over the past year4, so taking whatever floats to the top on a future npm install carries real drift risk; if you want to truly lock the version against future re-installs, pass --save-exact (or set save-exact=true in .npmrc) so npm writes 4.3.0 rather than ^4.3.0 to package.json.

What you do not install:

  • postcss — built into the Vite plugin.
  • autoprefixer — Tailwind v4 bakes the vendor-prefix work directly into its own output, targeting the official browser-support floor2, so a separate Autoprefixer pass is no longer part of the pipeline.
  • @tailwindcss/postcss — that package exists, but it is the alternative for stacks that require PostCSS (older Next.js setups, Angular). With @tailwindcss/vite installed, mounting @tailwindcss/postcss as well is redundant and a known footgun5.

Step 3 — Register the Vite plugin

Open vite.config.ts. Replace the scaffolded contents with:

import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [tailwindcss()],
});

That is the entire plugin wiring. The plugin handles class detection, the CSS transform, dependency tracking for HMR, and the production build. There is no content-globs section, no PostCSS chain, no manual purge step.

@tailwindcss/vite@4.3.0 lists Vite as a peer dependency with the range ^5.2.0 || ^6 || ^7 || ^86, so anything on the modern Vite 5+ line is supported.

Step 4 — Replace the scaffolded CSS with one @import

Open src/style.css (the default file create-vite generated). Delete every line. Put exactly this:

@import "tailwindcss";

That single import pulls in three Tailwind layers — theme, base, and utilities — using native CSS @layer ordering, plus the default token set the framework ships with7. The v3 trio @tailwind base; @tailwind components; @tailwind utilities; is the wrong incantation in v4 — the canonical form documented in the upgrade guide is @import "tailwindcss";8. Use it.

Save the file. Then in src/main.ts, make sure the very first line imports the CSS so Vite picks it up:

import "./style.css";

(create-vite already does this for you; just confirm.)

Step 5 — Define your design tokens with @theme

Replace the contents of src/style.css with this token set. Everything below ships into your CSS bundle as both :root variables AND generated utility classes — that is the magic of @theme.

@import "tailwindcss";

@theme {
  --color-canvas:    oklch(0.99 0 0);
  --color-surface:   oklch(0.97 0.005 240);
  --color-ink:       oklch(0.21 0.02 240);
  --color-ink-soft:  oklch(0.45 0.02 240);

  --color-brand-50:  oklch(0.97 0.04 250);
  --color-brand-100: oklch(0.93 0.07 250);
  --color-brand-500: oklch(0.60 0.18 250);
  --color-brand-600: oklch(0.52 0.20 250);
  --color-brand-700: oklch(0.44 0.21 250);

  --font-display:    "Inter", "system-ui", "sans-serif";

  --breakpoint-3xl:  120rem;
}

A few rules to internalize:

  • Token naming follows a namespace convention. --color-* registers paint colors and generates bg-*, text-*, border-*, ring-*, etc. --font-* generates font-*. --breakpoint-* extends the media query set so you can write 3xl:grid-cols-4. The full namespace list lives in the theme variables docs9.
  • @theme must sit at the top level. The Tailwind docs require theme variables to be defined at the top level — not nested under selectors or media queries9. Nested @theme blocks do not error at build time, but the variables get hoisted to :root anyway, so the apparent scoping is a no-op; use a regular CSS rule (.dark { --color-canvas: ...; }) when you actually want scoped overrides.
  • Tokens are CSS variables AND class generators. Defining --color-brand-500 gives you bg-brand-500, text-brand-500, ring-brand-500, etc., automatically, and also exposes the literal var(--color-brand-500) for any place you need it (inline styles, third-party libraries, CSS animations).

When to reach for @theme inline

There is a second form, @theme inline { ... }, that inlines the token's value into the generated utility rather than emitting a var(...) reference10. Compare the output:

/* @theme  →  .bg-brand-500 { background-color: var(--color-brand-500); } */
/* @theme inline  →  .bg-brand-500 { background-color: oklch(0.60 0.18 250); } */

Use plain @theme by default — it lets a parent selector override the variable (which is exactly how dark mode works in the next step). Switch a token to @theme inline only when you specifically want the generated utility to not participate in a variable cascade. The most common scenario is a value computed from other CSS variables, where you need the resolved value baked into the utility instead of a one-level-removed reference10.

Step 6 — Wire class-toggle dark mode with @custom-variant

Add this to src/style.css after the @import and before @theme:

@custom-variant dark (&:where(.dark, .dark *));

That single line redefines what the dark: variant matches: any element that is itself .dark, or any descendant of an element with .dark. Apply the class to <html> and every dark:bg-*, dark:text-*, dark:border-* in the tree fires.

Now override the variable values inside .dark. Append this block after the @theme block:

.dark {
  --color-canvas:   oklch(0.18 0.02 240);
  --color-surface:  oklch(0.24 0.02 240);
  --color-ink:      oklch(0.96 0.005 240);
  --color-ink-soft: oklch(0.75 0.01 240);
}

This is the cleanest pattern v4 unlocks: because @theme (without inline) emits utilities that read var(--color-canvas), the moment .dark rewrites --color-canvas, every bg-canvas element switches palette automatically — no dark:bg-canvas variant required on every node. The dark: variant is still available for the rare component that needs different behavior in dark mode beyond just a color flip.

If you prefer a data attribute over a class, swap the variant for:

@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));

Then toggle data-theme="dark" on <html> instead. The rest of the CSS does not change.

Step 7 — Persist the toggle with vanilla TypeScript

In src/main.ts, replace the scaffolded contents with:

import "./style.css";

const root = document.documentElement;

function applyStoredTheme(): void {
  const stored = localStorage.getItem("theme");
  if (stored === "dark") root.classList.add("dark");
  else if (stored === "light") root.classList.remove("dark");
}

applyStoredTheme();

document.addEventListener("click", (event) => {
  const target = event.target;
  if (!(target instanceof HTMLElement)) return;
  if (target.dataset.themeToggle === undefined) return;
  const next = root.classList.toggle("dark") ? "dark" : "light";
  localStorage.setItem("theme", next);
});

Now update index.html to render something worth looking at. Replace the body contents with:

<body class="min-h-screen bg-canvas text-ink antialiased">
  <main class="mx-auto max-w-2xl px-6 py-16">
    <h1 class="font-display text-4xl font-semibold tracking-tight">
      Hello, Tailwind v4
    </h1>
    <p class="mt-4 text-lg text-ink-soft">
      No config file. The CSS file is the config.
    </p>
    <button
      type="button"
      data-theme-toggle
      class="mt-8 rounded-md bg-brand-500 px-4 py-2 font-medium text-white shadow-xs hover:bg-brand-600"
    >
      Toggle dark mode
    </button>
  </main>
  <script type="module" src="/src/main.ts"></script>
</body>

Run npm run dev and click the button. The whole page should flip between light and dark palettes. Reload the page — the choice persists because of the localStorage round-trip on boot.

Step 8 — Define a custom utility with @utility

The v3 pattern of @layer utilities { .grid-fluid-3 { ... } } is replaced in v4 by the @utility directive. It registers a class that participates in Tailwind's variant system (you can write hover:grid-fluid-3, md:grid-fluid-3, dark:grid-fluid-3, etc.) and supports functional values via --value()7.

Add this to src/style.css:

@utility grid-fluid-* {
  display: grid;
  grid-template-columns: repeat(--value(integer), minmax(0, 1fr));
  gap: 1rem;
}

That defines a functional utility: any integer in the position of * becomes the column count. grid-fluid-3 produces three columns, grid-fluid-7 produces seven, and so on. The compiler only emits the variants you actually use in your markup.

Try it in index.html:

<div class="grid-fluid-3 mt-10">
  <div class="rounded-lg bg-surface p-4">Card 1</div>
  <div class="rounded-lg bg-surface p-4">Card 2</div>
  <div class="rounded-lg bg-surface p-4">Card 3</div>
</div>

For a non-integer utility, swap --value(integer) for one of the other resolution forms: bare types (--value(percentage), --value(number), --value(ratio)) accept matching bare values, bracketed types (--value([length]), --value([color])) accept the arbitrary-value square-bracket form like tab-[10rem], and namespace lookups (--value(--color-*)) resolve the suffix against a theme namespace7.

Step 9 — Force dynamic classes into the bundle with @source inline()

Tailwind only includes classes it can statically detect in your source files. That is fine for hard-coded markup, but it bites when you build class names from variables — bg-brand-${level} will not produce any utilities because the compiler never sees the concatenated string.

Two escape hatches exist:

  1. @source inline("...") — force a literal class name (or a brace-expanded set) into the bundle:

    @source inline("bg-brand-{50,100,500,600,700}");
    

    That generates bg-brand-50, bg-brand-100, bg-brand-500, bg-brand-600, and bg-brand-700 whether or not they appear in any HTML or TS file.

  2. @source not "<glob>" — exclude a directory from scanning. Tailwind v4 already honors .gitignore (so anything ignored there, including node_modules, is also skipped by the class scanner), but if you have a tracked legacy/ directory or a vendored UI kit you want Tailwind to ignore:

    @source not "../legacy/**";
    

    You can also negate inline class names with @source not inline("...") to suppress utilities that would otherwise be generated11.

If you want full manual control, append the source(none) modifier to the import itself — @import "tailwindcss" source(none); — which disables automatic scanning entirely and forces you to register every input path with explicit @source "..." lines11. Reach for that only on monorepos with unusual layouts; for most projects the defaults are right.

Step 10 — @apply and the @reference rule for scoped styles

@apply still exists in v4 and works inside top-level CSS — for example, inside a @utility block or a regular .btn { @apply ... } rule7. What changed is scoped styles. CSS Modules, Vue/Svelte/Astro <style> blocks, and component-scoped CSS files are processed in their own bundles by the build tool, which means they do not see your @theme, your custom utilities, or your @custom-variant definitions2.

If you need to use @apply inside one of those scoped contexts, add @reference at the top:

/* MyComponent.vue or some.module.css */
@reference "../app.css";

.card {
  @apply rounded-lg bg-surface p-4;
}

@reference imports your theme variables, custom utilities, and custom variants for the compiler without duplicating any CSS in the bundle. Without it, @apply rounded-lg errors with "Cannot apply unknown utility class" in the scoped file even though the same class works fine in your global CSS2.

For top-level CSS — including everything you have written in this tutorial so far in src/style.css@apply works without @reference.

Verification

Run the production build and inspect the output.

npm run build

You should see Vite 8 print something close to:

vite v8.0.14 building client environment for production...
✓ 5 modules transformed.
dist/index.html                 ~1 kB
dist/assets/index-{hash}.css    ~9 kB │ gzip: ~2.5 kB
dist/assets/index-{hash}.js     ~1 kB
✓ built in under 100ms

Exact sizes drift across Tailwind patch releases and depend on which utility classes you used.

A few invariants to spot-check in dist/assets/index-{hash}.css:

  • An @layer theme block contains --color-canvas, --color-brand-500, --font-display — proof your @theme tokens were promoted to CSS variables.
  • A .bg-brand-500 { background-color: var(--color-brand-500); } rule — proof utilities reference the variable, not an inlined literal (you would have used @theme inline for that).
  • A .dark { --color-canvas: oklch(...); ... } block — proof @custom-variant dark is wired.
  • A .grid-fluid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); ... } rule — proof your custom @utility compiled.
  • Five bg-brand-{level} rules even if only bg-brand-500 appears in the HTML — proof @source inline forced them in.

Open dist/index.html in a browser. The page should render in the light palette by default, and clicking the toggle button should switch every bg-canvas, bg-surface, text-ink, and text-ink-soft element to dark immediately, without any flash or DOM rewrite.

Troubleshooting

Cannot apply unknown utility class inside a Vue/Svelte/CSS-module file. The scoped bundle has no view of your @theme and custom utilities. Add @reference "../app.css"; (or wherever your Tailwind entry CSS lives) at the top of the scoped file. This is the canonical fix in the v4 docs2.

Tailwind compiles but no styles appear. The most common cause is forgetting @import "tailwindcss"; in your CSS, OR forgetting to actually import that CSS file from your TS entry (import "./style.css"; in src/main.ts). Vite needs both: a CSS file with the import directive, and a module graph edge from the entry to that CSS file. Check the network tab — if there is no assets/index-*.css request, the import edge is missing.

Classes built from template literals do not generate utilities. This is by design — Tailwind's class detection is a static scan over source text. Add @source inline("bg-brand-{50,100,500,600,700}"); (or the specific class names you compute at runtime) so they get generated even though the static scanner cannot see them11.

@tailwindcss/postcss is in package.json and the build is slow or duplicates rules. You probably installed both packages while debugging. Pick one path: @tailwindcss/vite for Vite, or @tailwindcss/postcss for stacks that need PostCSS. Running both at once stacks two passes of the compiler on the same input.

Upgrading an existing v3 project. Run the official upgrade tool: npx @tailwindcss/upgrade@4.3.0 inside the project root. It rewrites @tailwind base; @tailwind components; @tailwind utilities; to @import "tailwindcss";, migrates tailwind.config.js into a @theme block, and renames classes that shifted in v4 (most notably the shadow scale — shadow-sm becomes shadow-xs). The tool requires Node 20+. Tailwind's official guidance is to run it on a clean branch and git diff to review every change before committing8.

Next steps

  • Pair this setup with a typed form layer: see the Astro Actions type-safe forms tutorial for a Zod-validated form pattern that drops directly into a Tailwind v4 project.
  • Wire Tailwind v4 into a Next.js 16 codebase that already uses the App Router and React 19 Server Actions — see Next.js 16 Server Actions and React 19 Optimistic UI. The Tailwind setup is identical (CSS-first config), but Next.js currently routes through @tailwindcss/postcss rather than the Vite plugin.
  • Read the official Tailwind v4 release post for the full list of architectural changes; the 4.3 release notes cover the latest scrollbar utilities and new logical-property additions12.

References

Footnotes

  1. Node.js LTS schedule, https://nodejs.org/en/about/previous-releases — Node 24 is the current Active LTS through October 2026 as of May 2026. Vite 8's engines field requires ^20.19.0 || >=22.12.0.

  2. Tailwind CSS compatibility doc, https://tailwindcss.com/docs/compatibility — core framework needs Safari 16.4, Chrome 111, Firefox 128. Also defines the @reference rule for scoped styles. 2 3 4 5

  3. create-vite README, https://www.npmjs.com/package/create-vite9.0.7 released 2026-05-11; full template list and the --template flag.

  4. Tailwind CSS releases, https://github.com/tailwindlabs/tailwindcss/releases4.3.0 published 2026-05-08. v4.2.0 in February 2026 added Webpack support and new color palettes.

  5. Installing Tailwind CSS with PostCSS, https://tailwindcss.com/docs/installation/using-postcss — when to choose @tailwindcss/postcss vs @tailwindcss/vite.

  6. @tailwindcss/vite@4.3.0 package metadata (npm view @tailwindcss/vite), peer dependency vite: ^5.2.0 || ^6 || ^7 || ^8.

  7. Tailwind CSS functions and directives, https://tailwindcss.com/docs/functions-and-directives — covers @import "tailwindcss", @theme, @utility, @apply, --value(), @custom-variant, and the rest. 2 3 4

  8. Tailwind v3 → v4 upgrade guide, https://tailwindcss.com/docs/upgrade-guidenpx @tailwindcss/upgrade flow, Node 20+ requirement, the v4 class-renaming list. 2

  9. Tailwind CSS theme variables, https://tailwindcss.com/docs/theme — namespace rules, top-level requirement, what each --* prefix generates. 2

  10. GitHub discussion #18560, https://github.com/tailwindlabs/tailwindcss/discussions/18560@theme vs @theme inline output difference. 2

  11. Detecting classes in source files, https://tailwindcss.com/docs/detecting-classes-in-source-files@source, @source not, @source inline(), @source not inline(), and the source(none) modifier on @import "tailwindcss". 2 3

  12. Tailwind CSS v4.3 release post, https://tailwindcss.com/blog/tailwindcss-v4-3 — scrollbar utilities, new color values, and the v4.2 features rolled into 4.3.


FREE WEEKLY NEWSLETTER

Stay on the Nerd Track

One email per week — courses, deep dives, tools, and AI experiments.

No spam. Unsubscribe anytime.