---
title: 'Tailwind v4 + Vite from Scratch: CSS-First @theme (2026)'
description: 'Build a Vite + Tailwind CSS v4 project in 2026: the @theme directive, @custom-variant dark mode, @source paths, custom @utility, no config file.'
url: https://nerdleveltech.com/tailwind-v4-vite-css-first-theme-config-tutorial
language: en
date: '2026-05-26'
category: frontend
tags:
  - tailwind css
  - tailwind v4
  - vite
  - css first config
  - dark mode
  - typescript
  - frontend
markdown_url: https://nerdleveltech.com/tailwind-v4-vite-css-first-theme-config-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/tailwind-v4-vite-css-first-theme-config-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# 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 2026[^node-lts]. 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.4[^compat].
- 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.

```bash
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 flavors[^create-vite]. 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:

```bash
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.

```bash
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 year[^releases], 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 floor[^compat], 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 footgun[^postcss-pkg].

## Step 3 — Register the Vite plugin

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

```ts
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 || ^8`[^vite-peer], 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:

```css
@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 with[^functions]. 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";`[^upgrade]. Use it.

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

```ts
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`.

```css
@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 docs[^theme].
- **`@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 queries[^theme]. 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(...)` reference[^theme-inline]. Compare the output:

```css
/* @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 reference[^theme-inline].

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

By default Tailwind v4's `dark:` variant follows `prefers-color-scheme: dark` with zero config[^dark]. That is enough for many sites, but most production apps need a manual toggle that persists across sessions. The v3 `darkMode: 'class'` config key was removed; v4 uses `@custom-variant` in CSS instead.

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

```css
@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:

```css
.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:

```css
@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:

```ts
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:

```html
<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()`[^functions].

Add this to `src/style.css`:

```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`:

```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 namespace[^functions].

## 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:

   ```css
   @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:

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

   You can also negate inline class names with `@source not inline("...")` to suppress utilities that would otherwise be generated[^source].

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 "..."` lines[^source]. 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 ... }` rule[^functions]. 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` definitions[^compat].

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

```css
/* 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 CSS[^compat].

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.

```bash
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 docs[^compat].

**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 them[^source].

**`@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 committing[^upgrade].

## Next steps

- Pair this setup with a typed form layer: see [the Astro Actions type-safe forms tutorial](https://nerdleveltech.com/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](https://nerdleveltech.com/nextjs-16-server-actions-react-19-optimistic-ui-tutorial). 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](https://tailwindcss.com/blog/tailwindcss-v4) for the full list of architectural changes; the 4.3 release notes cover the latest scrollbar utilities and new logical-property additions[^v4-3].

## References

[^node-lts]: 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`.

[^compat]: 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.

[^create-vite]: create-vite README, https://www.npmjs.com/package/create-vite — `9.0.7` released 2026-05-11; full template list and the `--template` flag.

[^releases]: Tailwind CSS releases, https://github.com/tailwindlabs/tailwindcss/releases — `4.3.0` published 2026-05-08. v4.2.0 in February 2026 added Webpack support and new color palettes.

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

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

[^functions]: Tailwind CSS functions and directives, https://tailwindcss.com/docs/functions-and-directives — covers `@import "tailwindcss"`, `@theme`, `@utility`, `@apply`, `--value()`, `@custom-variant`, and the rest.

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

[^theme-inline]: GitHub discussion #18560, https://github.com/tailwindlabs/tailwindcss/discussions/18560 — `@theme` vs `@theme inline` output difference.

[^dark]: Tailwind CSS dark mode, https://tailwindcss.com/docs/dark-mode — default `prefers-color-scheme` behavior and the `@custom-variant dark (&:where(.dark, .dark *))` override pattern.

[^source]: 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"`.

[^upgrade]: Tailwind v3 → v4 upgrade guide, https://tailwindcss.com/docs/upgrade-guide — `npx @tailwindcss/upgrade` flow, Node 20+ requirement, the v4 class-renaming list.

[^v4-3]: 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.
