What is Tailwind CSS v4?

7 min read #Django #Tailwind CSS

Published: A practical guide to modern web development with Django

Introduction

If you've looked at the HTML behind this site, you've probably noticed the long class attributes โ€” text-4xl font-black tracking-tight, bg-base-100 border border-base-200 shadow-xl. That's Tailwind CSS, a utility-first CSS framework, and version 4 is the biggest rewrite it has ever had.

Tailwind v4 moves configuration out of JavaScript and into CSS itself. No tailwind.config.js, no PostCSS plugin chain required, no content array listing every template by hand. One @import "tailwindcss" and a stylesheet that describes your design tokens is the whole setup.

The Problem Tailwind Solves

Traditionally, styling meant writing CSS in one place and HTML in another:

  • You invent a class name (post-card-header-inner)
  • You write the rules in a stylesheet, possibly far from the markup
  • You guess whether a class is still used before deleting it
  • You fight specificity, !important, and stylesheet ordering
  • Your CSS bundle grows forever because nothing is ever safely removed

The result is a stylesheet that only grows, class names that mean nothing six months later, and a permanent fear of deleting anything.

The Tailwind Approach

Tailwind flips the model: instead of naming abstractions, you compose small, single-purpose classes directly in your markup.

Core idea: every visual decision is a class, and the build step emits only the classes you actually used.

<!-- A card, styled entirely in the markup -->
<article class="rounded-xl border border-base-200 bg-base-100 p-6 shadow-lg
                transition hover:-translate-y-0.5 hover:border-primary/30">
  <h2 class="text-lg font-bold tracking-tight">Hello</h2>
  <p class="mt-2 text-sm text-base-content/60">A short summary.</p>
</article>

There is no card.css to maintain. The styling lives next to the thing it styles, so deleting the markup deletes the CSS with it โ€” automatically, on the next build.

What changed in v4

v4 is not an incremental release. The important shifts:

  1. CSS-first configuration โ€” design tokens live in an @theme block, not a JS config file.
  2. Automatic content detection โ€” Tailwind scans your project for class names; no content globs by default (and @source when you need to point it somewhere explicit).
  3. A Rust-based engine โ€” full builds are dramatically faster, and incremental builds are near-instant.
  4. Native cascade layers โ€” output uses real @layer rules, so your custom CSS and utilities have predictable precedence.
  5. Modern CSS underneath โ€” oklch() colors, @property for gradients and transforms, container queries in core, @starting-style for entry animations.
  6. Fewer dependencies โ€” no PostCSS, autoprefixer, or postcss-import needed for the standard setup.

How This Site Uses Tailwind v4

This site builds its CSS with the standalone Tailwind CLI and a single input file, src/static/css/input.css. That file is short, and it is the entire configuration.

The stylesheet

@import "tailwindcss";

/* 1. Scan the project-level templates folder */
@source "../../templates/**/*.html";

/* 2. Scan template folders inside individual Django apps */
@source "../../**/templates/**/*.html";

@source not "./tailwindcss";
@source not "./daisyui{,*}.mjs";

Three things are happening:

  • @import "tailwindcss" pulls in the theme, preflight (reset), and utilities.
  • @source tells the scanner where Django templates live, because they are not where Tailwind would look by default. @source not excludes the vendored CLI binary and the daisyUI shims, which are not templates.
  • Nothing else is required. No content array, no plugin list in JS.

Design tokens and components

Custom utilities are defined with @layer components (or @utility for a single class), and they can @apply existing utilities:

@layer components {
  .tech-badge {
    @apply inline-flex items-center gap-2 rounded-xl border border-base-content/10
           bg-base-100 px-3 py-1.5 shadow-lg transition-all hover:-translate-y-0.5
           hover:bg-base-200;
  }

  .post-body h2 {
    @apply mt-10 text-2xl font-bold tracking-tight text-base-content;
  }
}

That is how rendered Markdown gets styled: the blog writes Post.body_html, and the .post-body rules give headings, lists, blockquotes, tables, and code blocks a consistent look without a typography plugin.

Plugins

daisyUI is loaded with the @plugin directive and configured inline โ€” no JS config file:

@plugin "./daisyui.mjs" {
  themes:
    winter --default,
    night --prefersdark,
    silk;
}

Those theme names are why the markup can say bg-base-100, text-base-content, btn, or input input-bordered and get sensible colors in both light and dark mode. Theme switching is then just a data-theme attribute:

<div data-theme="{{ current_theme|default:'winter' }}"
     class="bg-base-100 text-base-content transition-colors duration-300">

The build

The project vendors the CLI binary and runs it directly โ€” no Node.js, no npm install:

./src/static/css/tailwindcss \
  -i src/static/css/input.css \
  -o src/static/css/output.css

output.css is committed to the repository, because the Docker image runs collectstatic but never builds CSS. The binary and the daisyUI package are gitignored; ./scripts/install-assets.sh downloads them and rebuilds on a fresh clone.

Key Concepts

Concept Purpose Example
@import "tailwindcss" Load theme, preflight, utilities @import "tailwindcss";
@theme Define design tokens --color-brand-500: oklch(0.72 0.19 145);
@source Add or exclude scanned paths @source "../../templates/**/*.html";
@source not Skip a path entirely @source not "./daisyui/**";
@utility Define one custom utility @utility content-auto { ... }
@custom-variant Define or override a variant @custom-variant dark (&:where(.dark, .dark *));
@plugin Load a JS-based plugin @plugin "./daisyui.mjs" { ... }
@apply Inline utilities into custom CSS @apply rounded-lg border p-4;
@variant Use a variant inside custom CSS @variant dark { background: black; }

Tailwind + Django

The combination is mostly about telling the scanner where your templates are.

/* input.css */
@import "tailwindcss";

@source "../../templates/**/*.html";
@source "../../**/templates/**/*.html";
<!-- templates/base.html -->
{% load static %}
<link rel="stylesheet" href="{% static 'css/output.css' %}">

<body class="min-h-screen bg-base-100 text-base-content">
  {% block content %}{% endblock %}
</body>

Flow: 1. You write utilities in Django templates 2. The CLI scans those templates and collects the class names it finds 3. It emits exactly those rules into output.css 4. Whitenoise serves the built file in production 5. Any class you add that isn't in a scanned file simply won't exist โ€” so keep the @source paths honest

The one rule that bites everyone: class names must appear as complete strings. Tailwind scans text, it does not run your template logic.

<!-- โœ— not detected: the scanner sees "text-" and "{{ color }}" separately -->
<p class="text-{{ color }}-500">

<!-- โœ“ detected: full class names, chosen in Python or with an if -->
<p class="{% if error %}text-error{% else %}text-base-content{% endif %}">

Real-World Use Cases

Tailwind is a good fit for:

  • Server-rendered apps โ€” Django, Rails, Laravel: no component runtime, just HTML
  • Design systems โ€” tokens in @theme, components in @layer components
  • Rapid prototyping โ€” style in the template, see it on reload
  • Multi-theme sites โ€” daisyUI themes driven by a data-theme attribute
  • Markdown/CMS output โ€” style arbitrary HTML with descendant rules in @layer components
  • Small teams โ€” no naming debates, no dead CSS to audit

It is a worse fit when the markup must stay minimal (email templates, third-party widgets) or when you need CSS shipped without a build step at all.

Getting Started

  1. Get the CLI. Either npm install tailwindcss @tailwindcss/cli, or vendor the standalone binary the way this project does.

  2. Write an input file:

@import "tailwindcss";

@theme {
  --font-display: "Satoshi", sans-serif;
  --color-brand-500: oklch(0.72 0.19 145);
  --breakpoint-3xl: 1920px;
}
  1. Point it at your templates with @source if they live outside the default scan.

  2. Build once, then watch:

./tailwindcss -i input.css -o output.css --watch
  1. Style the markup first, abstract later. Reach for @apply in @layer components only when the same cluster of utilities repeats three or more times.

The Philosophy

Tailwind v4 isn't about avoiding CSS. It's about removing the layer of invented names and unused rules that sits between your markup and your styles.

By moving configuration into the stylesheet, v4 makes the framework feel like CSS rather than a JavaScript tool that happens to produce CSS. The build is faster, the setup is smaller, and the output contains only what the templates actually use.

As this site demonstrates, you can run a themed, responsive, dark-mode-capable front end from one short input file and a handful of directives โ€” with no Node toolchain in the deployment path at all.


This site was built with Django 6.0+, HTMX, Tailwind CSS v4 (with daisyUI), and SQLite. All blog posts are authored in the Django admin as Markdown, rendered to HTML on save. CSS is built with the vendored Tailwind CLI into a committed output.css, and the Docker image only collects static files โ€” it never builds CSS.

Read more about the tech stack