ehsan.blog
~/blog/how-to-style-an-astro-blog-with-tailwind-v4 — zsh
cat how-to-style-an-astro-blog-with-tailwind-v4.md

How to style an Astro blog with Tailwind v4 and no config file

·4 min read

If you last used Tailwind a couple of years ago, you remember a tailwind.config.js file where you extended colors, fonts, and spacing in a big JavaScript object. Tailwind v4 throws that model out. Configuration now lives in your CSS, and for a lot of projects — including this blog — there’s no config file at all.

Here’s exactly how this Astro blog is set up.

Step 1: The Vite plugin

Astro builds on Vite, and Tailwind v4 ships a first-party Vite plugin. Instead of a PostCSS config and a content-scanning setup, you add one plugin:

mjs
// blog/astro.config.mjs
import { defineConfig } from "astro/config"
import tailwindcss from "@tailwindcss/vite"

export default defineConfig({
  site: "https://blog.developerehsan.com",
  integrations: [/* mdx, sitemap, pagefind... */],
  vite: {
    plugins: [tailwindcss()],
  },
})

That’s the entire build-side setup. The @tailwindcss/vite plugin handles scanning your files for class names and generating CSS on the fly. There’s no content: [...] array to maintain — v4 figures out what to scan automatically.

Notice there’s no @astrojs/tailwind integration in the integrations array. In v4 you use the Vite plugin directly rather than an Astro-specific wrapper.

Step 2: Import Tailwind in your CSS

In v3 you wrote three @tailwind base/components/utilities directives. In v4 it’s a single import at the top of your global stylesheet:

css
/* blog/src/styles/global.css */
@import "tailwindcss";
@import "@fontsource-variable/jetbrains-mono";

That one @import "tailwindcss" pulls in the reset, the utility classes, and everything else. The second import here just loads the JetBrains Mono variable font this blog uses — a normal CSS import, nothing Tailwind-specific.

Step 3: Define your design tokens with @theme

This is the big idea in v4. Instead of a JS config object, you declare your design tokens in a special @theme block. This blog’s entire base palette is four colors and a font:

css
/* blog/src/styles/global.css */
@theme {
  --font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace,
    monospace;
  --color-bg: oklch(0.05 0 0);
  --color-bg-elevated: oklch(0.08 0 0);
  --color-fg: oklch(0.92 0 0);
  --color-border: oklch(0.2 0 0);
}

The magic is in the naming convention. Tailwind reads these CSS variables and generates matching utility classes from them:

  • --color-bg → you can now write bg-bg, text-bg, border-bg, etc.
  • --color-bg-elevatedbg-bg-elevated
  • --color-fgtext-fg
  • --color-borderborder-border
  • --font-monofont-mono

So the token is the config. Add a --color-* variable and you instantly have color utilities for it. There’s no separate “here’s the value” and “here’s the class name” step — declaring the variable does both.

My portfolio uses the same CSS-first approach — see configuring Tailwind v4 inline with @theme there. A quick note on the color values: these are oklch(...) colors. OKLCH is a modern color space that’s perceptually uniform, which makes it easy to reason about lightness — oklch(0.05 0 0) is near-black, oklch(0.92 0 0) is near-white. The comment at the top of the real file even documents a contrast floor (nothing informational below oklch(0.6 0 0)) to keep text readable on the dark background.

flowchart LR
  A["@theme --color-bg"] --> B[Tailwind reads token]
  B --> C["utilities: bg-bg, text-bg, border-bg"]

Step 4: Plain CSS still works — and mixes in

Not everything wants to be a utility class. This blog’s terminal aesthetic — scanlines, glowing text, card chrome — is written as ordinary CSS classes right alongside the @theme block in the same file:

css
/* blog/src/styles/global.css */
.terminal-card {
  background: oklch(0.08 0 0);
  border: 1px solid oklch(0.2 0 0);
  transition:
    border-color 0.3s,
    box-shadow 0.3s,
    transform 0.3s;
}
.terminal-card:hover {
  border-color: oklch(0.4 0 0);
  box-shadow: 0 0 20px oklch(0.92 0 0 / 0.04);
}

.text-glow {
  text-shadow:
    0 0 24px oklch(0.92 0 0 / 0.5),
    0 0 48px oklch(0.92 0 0 / 0.2);
}

Because the whole system lives in CSS now, there’s no awkward boundary between “Tailwind stuff” and “my custom CSS.” Utilities and hand-written rules sit in the same file and reference the same tokens.

Why no config file is a good thing

A few concrete wins from the CSS-first approach:

  • One source of truth. Your tokens and your custom CSS live together, in the language they’re actually applied in.
  • Less indirection. In v3 you’d jump to tailwind.config.js, add a color, then use it in a class. In v4 you add a --color-* variable and the class exists. Same file, one step.
  • Real CSS variables. The tokens are genuine CSS custom properties, so you can also use them in plain CSS (var(--color-fg)) or override them per-scope.
  • Less to configure. No content globs, no PostCSS chain — just the Vite plugin and an import.

What to remember

  • Tailwind v4 is CSS-first: configure it in your stylesheet, not a JS config file.
  • Wire it into Astro with the @tailwindcss/vite plugin in astro.config.mjs — no @astrojs/tailwind integration needed.
  • @import "tailwindcss"; replaces the old three @tailwind directives.
  • Define tokens in @theme using the --color-* / --font-* naming convention, and Tailwind generates matching utilities automatically.
  • Custom CSS and Tailwind utilities now share one file and one set of tokens.
ls ./related
cat ./comments

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