Utility-First CSS and Tailwind
Prerequisites: CSS architecture and BEM
Utility-first CSS builds interfaces from many small, single-purpose classes applied directly in the HTML. Tailwind CSS is the most popular utility framework.
<a href="/rye" class="rounded-lg bg-brand px-4 py-2 text-white hover:bg-brand/90 md:px-6">View rye loaf</a>
Tailwind scans your files for class names and generates CSS only for
the classes you use. With a custom --color-brand theme color, Tailwind
v4.3 generated exactly this for the link above:
@layer theme {
:root, :host {
--color-white: #fff;
--spacing: 0.25rem;
--radius-lg: 0.5rem;
--color-brand: oklch(55% 0.18 260);
}
}
@layer utilities {
.rounded-lg {
border-radius: var(--radius-lg);
}
.bg-brand {
background-color: var(--color-brand);
}
.px-4 {
padding-inline: calc(var(--spacing) * 4);
}
.py-2 {
padding-block: calc(var(--spacing) * 2);
}
.text-white {
color: var(--color-white);
}
@media (hover: hover) {
.hover\:bg-brand\/90:hover {
background-color: color-mix(in srgb, oklch(55% 0.18 260) 90%, transparent);
}
@supports (color: color-mix(in lab, red, red)) {
.hover\:bg-brand\/90:hover {
background-color: color-mix(in oklab, var(--color-brand) 90%, transparent);
}
}
}
@media (width >= 48rem) {
.md\:px-6 {
padding-inline: calc(var(--spacing) * 6);
}
}
}
Things to notice:
- Every utility reads a theme variable (
--spacing,--radius-lg,--color-brand), so the design system lives in custom properties. hover:became a:hoverrule wrapped in@media (hover: hover)so touch screens don't get stuck hover states;md:became awidth >= 48remmedia query.bg-brand/90(90% opacity) usescolor-mix(), with ansrgbfallback for older browsers.- Utilities live in
@layer utilities— cascade layers, from this level's earlier lessons.
Setting it up (v4)
/* illustrative: Tailwind-specific CSS, processed by the Tailwind build */
@import "tailwindcss";
@theme {
--color-brand: oklch(55% 0.18 260);
--font-display: "Inter", sans-serif;
}
Tailwind v4 is configured in CSS: @import "tailwindcss" brings in
the reset (Preflight), theme, and utilities; @theme adds or overrides
tokens, and each token creates matching utilities (bg-brand,
text-brand, font-display). It runs through a Vite plugin
(@tailwindcss/vite), a PostCSS plugin, or its CLI.
Trade-offs
| Pros | Cons |
|---|---|
| No naming, no dead CSS, small output | Long class lists in HTML |
| Constrained design tokens keep the UI consistent | Learning the utility vocabulary |
| Changes are local — no fear of breaking other pages | Repetition across templates unless you extract components |
| Works well with component frameworks | Harder to read the CSS in DevTools |
Keeping it maintainable
- Extract repetition into components in your framework or template language (a Blade component, a React component) rather than into CSS.
- Use
@applysparingly — it recreates the problems utility classes were meant to avoid. - Class names must appear whole in your source files. Tailwind
scans text; it can't see a class built at run time from pieces like
"bg-" + color. Write the full names, for example in a lookup table. - Plain CSS is still fine next to Tailwind for complex things
(animations,
:has()logic, third-party widgets).
Utility classes without Tailwind
Even non-utility projects keep a few helpers: .visually-hidden,
.flow > * + * { margin-block-start: 1em; }, .text-center.