Advanced Tailwind CSS Techniques
Master advanced Tailwind CSS v4 techniques — CSS-first theming, custom variants, container queries, and dynamic design systems.
Bagas
@bagas.coTailwind CSS has quickly become a favorite among front-end developers for its utility-first approach, letting you build fast without leaving your markup. Version 4 pushed this further: configuration moved from a JavaScript file into plain CSS, the engine got dramatically faster, and a handful of long-requested features — container queries, arbitrary variants, @starting-style support — finally shipped as first-class utilities. This post walks through the techniques that separate a basic Tailwind setup from a properly tuned design system.
1. CSS-first theming with @theme
Tailwind v4 no longer needs a tailwind.config.js for most projects. Design tokens — colors, fonts, spacing, breakpoints — are declared directly in CSS using the @theme directive, and Tailwind turns each token into both a utility class and a native CSS variable.
/* app.css */
@import "tailwindcss";
@theme {
--color-primary: oklch(0.58 0.2 265);
--color-secondary: oklch(0.62 0.24 320);
--color-accent: oklch(0.75 0.15 195);
--font-sans: "Inter", sans-serif;
--font-serif: "Merriweather", serif;
--breakpoint-tablet: 40rem;
--breakpoint-laptop: 64rem;
}Every token is now available both as a utility and as a CSS variable, so bg-primary and var(--color-primary) always agree:
<div className="bg-primary text-white p-4 font-sans">
Tailwind custom theming in action!
</div>Colors declared with oklch() interpolate more predictably than hex or HSL when you generate shades or animate between them — worth adopting even if you're just replacing existing hex values.
2. Container queries, not just media queries
Media-query breakpoints (sm:, md:, lg:) respond to the viewport. That falls apart the moment a component — a card, a sidebar widget — gets reused in a narrower column than the page itself. Tailwind v4 ships container queries as built-in utilities, no plugin required.
<div className="@container">
<div className="grid grid-cols-1 @sm:grid-cols-2 @lg:grid-cols-3 gap-4">
<Card />
<Card />
<Card />
</div>
</div>@sm: and @lg: here respond to the width of the nearest @container, not the viewport — so the same <Card /> grid lays out correctly whether it's rendered full-width or squeezed into a narrow sidebar.
3. Custom variants and smarter dark mode
Dark mode is still a dark: prefix, but you can now define arbitrary variants with @custom-variant instead of reaching for a plugin. A common one: toggling dark mode from a data-theme attribute instead of the dark class.
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));<div className="bg-white dark:bg-gray-800 text-black dark:text-white p-4">
This component supports dark mode.
</div>Flip <html data-theme="dark"> and every dark: utility on the page responds — no config file, no darkMode: "class" setting to remember.
State-based variants get the same treatment. has-* reaches into a parent to check its children, and not-* negates any variant:
<label className="group flex items-center gap-2 has-checked:text-primary">
<input type="checkbox" className="peer" />
<span className="not-peer-checked:opacity-50">Enable notifications</span>
</label>4. Complex layouts with grid and flex
Tailwind's grid and flex utilities still do the heavy lifting for layout — v4 adds grid-cols-subgrid for aligning nested grids to a parent's tracks, useful for cards that need their header/body/footer rows to line up across a row of unequal content.
<div className="grid grid-cols-1 tablet:grid-cols-2 laptop:grid-cols-3 gap-4">
<div className="bg-primary text-white p-4 rounded-lg">Item 1</div>
<div className="bg-secondary text-white p-4 rounded-lg">Item 2</div>
<div className="bg-accent text-white p-4 rounded-lg">Item 3</div>
</div>This produces a single column on small screens, two at the tablet breakpoint we defined in @theme, and three at laptop — all without touching a config file.
5. Animations defined in @theme
Custom animations move into @theme the same way colors do. Declare the keyframes once, then reference them by name.
@theme {
--animate-fade-in: fade-in 0.6s ease-out forwards;
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}<div className="opacity-0 animate-fade-in">This text will fade in.</div>Pair it with @starting-style for entry animations on elements that use display: none right up until they appear (dialogs, popovers) — something CSS couldn't express cleanly before v4.
6. Dynamic theming with CSS variables
Because @theme tokens compile down to real CSS variables, swapping an entire palette at runtime is just a matter of overriding those variables on a scope — no @apply indirection needed.
:root {
--color-bg: oklch(0.98 0 0);
--color-text: oklch(0.2 0 0);
}
[data-theme="dark"] {
--color-bg: oklch(0.15 0 0);
--color-text: oklch(0.95 0 0);
}<div className="bg-[var(--color-bg)] text-[var(--color-text)] p-4">
Theme-based styling with CSS variables.
</div>Toggling data-theme on <html> or <body> now drives both your custom CSS and every Tailwind utility built from those tokens — one source of truth for the whole theme.
Conclusion
The shift to CSS-first configuration in Tailwind v4 isn't just a syntax change — it collapses the gap between "the value in my config" and "the value in my stylesheet" into a single CSS variable, and it unlocks features like container queries and @starting-style that a JS config couldn't express. If you're still on a tailwind.config.js-based v3 setup, the techniques above are the ones worth learning first when you migrate.