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:
- CSS-first configuration โ design tokens live in an
@themeblock, not a JS config file. - Automatic content detection โ Tailwind scans your project for class names; no
contentglobs by default (and@sourcewhen you need to point it somewhere explicit). - A Rust-based engine โ full builds are dramatically faster, and incremental builds are near-instant.
- Native cascade layers โ output uses real
@layerrules, so your custom CSS and utilities have predictable precedence. - Modern CSS underneath โ
oklch()colors,@propertyfor gradients and transforms, container queries in core,@starting-stylefor entry animations. - Fewer dependencies โ no PostCSS, autoprefixer, or
postcss-importneeded 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.@sourcetells the scanner where Django templates live, because they are not where Tailwind would look by default.@source notexcludes the vendored CLI binary and the daisyUI shims, which are not templates.- Nothing else is required. No
contentarray, 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-themeattribute - 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
-
Get the CLI. Either
npm install tailwindcss @tailwindcss/cli, or vendor the standalone binary the way this project does. -
Write an input file:
@import "tailwindcss";
@theme {
--font-display: "Satoshi", sans-serif;
--color-brand-500: oklch(0.72 0.19 145);
--breakpoint-3xl: 1920px;
}
-
Point it at your templates with
@sourceif they live outside the default scan. -
Build once, then watch:
./tailwindcss -i input.css -o output.css --watch
- Style the markup first, abstract later. Reach for
@applyin@layer componentsonly 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.