This page was opened from file://. Chromium blocks the local CSS and JS for this site in that mode. Preview it over HTTP with pnpm dev or pnpm preview.

Skip to main content
dout.dev Frontend notes, design systems, and the sharp edges of shipping.

Article

/ Archive /

It Is Time to Bury Utility CSS Frameworks (Yes, Even Tailwind)

The platform moved. Tailwind didn't get the memo.

It Is Time to Bury Utility CSS Frameworks (Yes, Even Tailwind)

Article content

The platform moved. Tailwind didn't get the memo.

Italo Baeza Cabrera wrote a measured piece asking whether it is time to move on from Tailwind CSS. His conclusion was careful: Tailwind is overkill for presentational sites, still useful for high-interactivity apps when you build a proper three-layer design system on top of it. I think Italo was too generous.

The question is not whether Tailwind is sometimes useful. The question is whether it is still the best tool for the job it claims to solve. And the answer, in 2026, is no. The platform absorbed every problem Tailwind was built to solve, and the framework's remaining value proposition is a build step you don't need, a class-name tax you shouldn't pay, and a specificity model that fights the cascade instead of working with it.

This is not a "Tailwind sucks" post. Tailwind was brilliant in 2017. The problem is that 2017 was almost a decade ago, and CSS is not the same language anymore.

What Tailwind solved, and why it doesn't need solving anymore

Let's be fair. When Tailwind appeared, it addressed four genuine pain points:

  1. Naming things is hard. No more arguing whether this is a .card, .container, .panel, or .box. Utility classes sidestep naming entirely.
  2. Design tokens were inaccessible. #876876 means nothing. bg-red-500 means something. A constrained palette of values kept teams consistent.
  3. CSS had no reusable primitives. Functions, mixins, nesting-these required Sass or PostCSS. Tailwind bundled the preprocessor and the design system in one package.
  4. Onboarding was expensive. Every project had its own CSS conventions. Tailwind was a shared language that transferred across jobs.

In 2017, every one of those was a real problem. In 2026, none of them are.

Naming is solved by the platform, not by refusing to name things

The Tailwind solution to naming was to eliminate names: don't call it a card, call it rounded-lg bg-white p-4 shadow-md. This works until you have twenty cards on a page, each with the same fourteen-class string repeated identically. At that point, you have not eliminated the abstraction-you have just refused to give it a name, and now the abstraction lives as a string literal duplicated across your markup instead of as a single CSS class.

Native CSS gives you better options:

/* Option A: a semantic class backed by custom properties */
.card {
  border-radius: var(--radius-lg);
  background: var(--color-white);
  padding: var(--spacing-4);
  box-shadow: var(--shadow-md);
}

/* Option B: an attribute selector backed by custom properties */
[data-component="card"] {
  border-radius: var(--radius-lg);
  background: var(--color-white);
  padding: var(--spacing-4);
  box-shadow: var(--shadow-md);
}

The attribute selector approach has a property that utility classes lack: the selector itself carries semantic meaning. class="rounded-lg bg-white p-4 shadow-md" tells you what the element looks like. [data-component="card"] tells you what the element is. When you read the HTML, you understand the document structure. When you read the CSS, you find the component definition in one place, not scattered across every instance in the markup.

Design tokens are custom properties, and they work without a build step

:root {
  --color-primary: oklch(0.55 0.2 260);
  --color-primary-hover: oklch(0.55 0.2 260 / 0.8);
  --radius-sm: 0.25rem;
  --radius-md: 0.5rem;
  --radius-lg: 0.75rem;
  --shadow-sm: 0 1px 3px oklch(0 0 0 / 0.1);
  --shadow-md: 0 4px 6px oklch(0 0 0 / 0.1);
  --spacing-2: 0.5rem;
  --spacing-4: 1rem;
}

Every one of Tailwind's @theme tokens maps directly to a custom property. The difference is that custom properties work in every browser, require zero build steps, are debuggable in DevTools, and can be changed at runtime. Tailwind's @theme block compiles down to… custom properties. You are running a build step to generate something the browser already supports natively.

The preprocessor is obsolete

Italo mentions that "pairing Tailwind CSS with a CSS processor is the only way to make the framework extend its legs." That is an admission of failure. If your utility framework requires a preprocessor to be useful, and the preprocessor features you need (nesting, mixins, color manipulation) are now native to CSS, then the utility framework is a middleman. Cut the middleman.

What Sass gave us → what CSS absorbed:

  • $variable--custom-property
  • darken($color, 10%)color-mix(in srgb, var(--color), black 10%)
  • @mixin → CSS Functions and Mixins Module (working draft, shipping behind flags)
  • Nesting → native CSS nesting (& syntax)
  • @if / @else → CSS if() function (shipping in Chrome)

The pipeline that was once Sass → PostCSS → Tailwind → Autoprefixer → CSS is now CSS. One step. Zero dependencies.

The class-name tax: why utility classes are wasteful at scale

Every Tailwind class on an element is a string the browser must parse, intern, and match against the stylesheet. Twenty classes on an element means twenty lookups. Now multiply by a hundred elements on a page. Now multiply by every page on your site.

<!-- This element carries fourteen class lookups -->
<div class="flex flex-col gap-4 p-6 bg-white rounded-lg shadow-md border border-gray-200 hover:shadow-lg transition-shadow duration-200">

Compare:

<!-- This element carries one attribute lookup -->
<div data-component="card">

The CSS for the second approach:

[data-component="card"] {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-4);
  padding: var(--spacing-6);
  background: var(--color-white);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-md);
  border: 1px solid var(--color-gray-200);
  transition: box-shadow 200ms ease;
}

[data-component="card"]:hover {
  box-shadow: var(--shadow-lg);
}

Same visual result. One attribute selector instead of fourteen classes. The browser does one lookup, not fourteen. The HTML is readable. The CSS is maintainable. The abstraction has a name.

Attribute selectors are not a hack-they are a feature

Every time I suggest [data-component="card"] over class="card", someone objects that attribute selectors are "slower" than class selectors. Let me kill this myth: the performance difference between a class selector and an attribute selector is below the noise floor for any real application. Browsers optimize both to the same order of magnitude. The thing that actually slows down style resolution is the number of rules and the complexity of the cascade, not the selector type.

What attribute selectors give you that classes do not:

  • State as style. [aria-expanded="true"], [aria-current="page"], [data-loading]-these selectors tie visual state directly to semantic state. When your accessible markup is correct, your styles are automatically correct too. No class="is-active is-expanded" sync bugs.
  • A flat, predictable specificity. Attribute selectors have the same specificity as class selectors (0,1,0). They don't create specificity wars. Combined with @layer, you control the cascade explicitly.
  • Self-documenting markup. <nav data-component="breadcrumb" aria-label="Breadcrumb"> tells you everything. <nav class="flex gap-2 text-sm text-gray-500"> tells you nothing about what it is, only how it looks right now.
  • No naming collision. [data-component="card"] will never conflict with a third-party library's .card class. The data- namespace is yours.

Specificity without the war: @layer fixes what utility frameworks broke

Tailwind's approach to specificity is brute force: generate every utility at the same specificity level (0,1,0 for classes) and let the cascade's source order resolve conflicts. When that fails-when a component style needs to override a utility-you have two options: !important (which Tailwind uses liberally) or the @layer directive (which Tailwind added in v3 to fix the mess !important created).

Native CSS gives you @layer without the framework:

@layer reset, tokens, utilities, components, overrides;

/* reset layer: lowest priority */
@layer reset {
  *, *::before, *::after { box-sizing: border-box; margin: 0; }
}

/* tokens layer: design decisions */
@layer tokens {
  :root {
    --radius-md: 0.5rem;
    --color-primary: oklch(0.55 0.2 260);
  }
}

/* utilities layer: single-purpose, composable */
@layer utilities {
  .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
  .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}

/* components layer: your design system */
@layer components {
  [data-component="card"] { /* ... */ }
  [data-component="btn"] { /* ... */ }
}

/* overrides layer: one-off adjustments, always wins */
@layer overrides {
  .home-page [data-component="card"] { /* special case */ }
}

The cascade is explicit. You know exactly what overrides what. No !important, no framework-mandated specificity battles, no generated CSS that weighs 300 KB. The @layer order is declared once, and every rule in every file respects it. This is what Tailwind's @layer base, @layer components, @layer utilities was trying to be-except you control it, the browser enforces it, and no build step is required.

The build step is a liability, not a feature

Tailwind requires a build step. Full stop. You need PostCSS, you need the Tailwind plugin, you need the content paths configured, you need the @tailwind directives processed. This adds:

  • A dependency chain. Tailwind depends on PostCSS, which depends on Node.js, which depends on your operating system's ability to run Node. Every link in this chain is a potential breakage point.
  • Dev server latency. Every class change requires a rebuild. The Tailwind JIT engine is fast, but "fast" is still slower than "instant." Native CSS changes apply on save with zero processing.
  • Debugging indirection. The class you wrote (bg-red-500) is not the CSS the browser applies. What the browser sees is a generated rule somewhere in a 200 KB stylesheet. DevTools can map it back, but you have added an indirection layer between author intent and browser reality.
  • An onboarding barrier. A new team member must understand Tailwind's class naming conventions, the configuration file, the content scanning system, the @layer directives, the @apply escape hatch, the theme() function, and the arbitrary value syntax (w-[327px]). Compare: to work with native CSS, they need to understand CSS.

The build step made sense when it was doing real work: transpiling modern syntax, removing unused styles, optimizing for production. In 2026, every browser you care about supports modern syntax natively. Tree-shaking CSS matters less with HTTP/2 multiplexing and @layer-organized stylesheets that are small by design. The build step is now a ritual performed because the framework requires it, not because the output needs it.

The @apply escape hatch admits the framework failed

Tailwind's @apply directive exists because even Tailwind's creators understand that repeating the same fourteen classes on every card instance is untenable:

.card {
  @apply rounded-lg bg-white p-4 shadow-md;
}

This is the framework admitting that classes are a better abstraction than utility strings for reusable components. But @apply combines the worst of both worlds: you still need the build step, you still pay the specificity tax, and now you have a non-standard syntax (@apply) that will never work in a browser.

The native equivalent is shorter, portable, and requires zero tooling:

.card {
  border-radius: var(--radius-lg);
  background: var(--color-white);
  padding: var(--spacing-4);
  box-shadow: var(--shadow-md);
}

Custom properties are the variables. The component class is the abstraction. The cascade handles the rest. What exactly did @apply add to this equation? A build step and a dependency.

AI makes utility frameworks even less necessary

Every major LLM writes standard CSS fluently. They do not need a cheat sheet for Tailwind class names because they were trained on the entire corpus of web development, which is overwhelmingly standard CSS. When an LLM generates class="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors", it is hallucinating Tailwind syntax from memorized patterns-and it gets it wrong approximately as often as it gets it right because the training data is noisy.

When the same LLM generates:

.btn-primary {
  display: flex;
  align-items: center;
  gap: var(--spacing-2);
  padding: var(--spacing-2) var(--spacing-4);
  background: var(--color-primary);
  color: var(--color-white);
  border-radius: var(--radius-md);
  transition: background 200ms ease;
}
.btn-primary:hover { background: var(--color-primary-hover); }

…the output is verifiable. Every property is a standard CSS feature. Every value is a standard CSS value. You can read it, test it, diff it, and debug it with the same tools you use for the rest of your codebase. The LLM is generating standard web platform code, not framework-specific incantations.

This matters because AI-assisted development shifts the bottleneck from code generation speed to code verification speed. Standard CSS verifies faster than framework CSS because there are fewer layers to audit, fewer dependencies to trust, and fewer build artifacts to inspect.

When do utility classes still make sense?

If you read this far thinking I am about to say "never," here is the nuance.

A small set of genuinely single-purpose utility classes still earns its keep. sr-only, truncate, visually-hidden-these are one-property classes that express a single concept and never change. They are closer to "design tokens in selector form" than to "a framework-level styling strategy."

The key distinction is who defines the utility and how many there are. A project-specific @layer utilities block with fifteen classes, curated by your team, matching your design system, is a reasonable abstraction. A third-party framework that generates ten thousand classes, most of which you will never use, is a dependency tax you should refuse to pay.

The line is: if your utility class is a thin wrapper around a custom property, it is a design token, and that is fine. truncate wraps text-overflow: ellipsis. sr-only wraps an accessible hiding pattern. These are not "Tailwind lite." These are platform abstractions that happen to be expressed as classes.

The three-layer system without the framework

Italo described his three-layer approach: utilities → elements → blocks, built on Tailwind's @theme. Here is the same system in native CSS, with zero dependencies:

/* --- Layer 1: Semantic tokens (the @theme replacement) --- */
:root {
  --color-primary: oklch(0.55 0.2 260);
  --color-primary-hover: oklch(0.55 0.2 260 / 0.85);
  --color-muted: oklch(0.7 0.01 260);
  --color-bg: oklch(1 0 0);
  --radius-sm: 0.25rem;
  --radius-md: 0.5rem;
  --radius-lg: 0.75rem;
  --shadow-sm: 0 1px 3px oklch(0 0 0 / 0.1);
  --shadow-md: 0 4px 6px oklch(0 0 0 / 0.1);
  --shadow-neon: 0 0 12px oklch(0.7 0.2 260 / 0.5);
  --spacing-2: 0.5rem;
  --spacing-4: 1rem;
  --spacing-6: 1.5rem;
}

/* --- Layer 2: Elements (the .card, .btn replacement) --- */
[data-element="card"] {
  border-radius: var(--radius-lg);
  background: var(--color-bg);
  padding: var(--spacing-4);
  box-shadow: var(--shadow-sm);
}

[data-element="btn"] {
  display: inline-flex;
  align-items: center;
  gap: var(--spacing-2);
  padding: var(--spacing-2) var(--spacing-4);
  border-radius: var(--radius-md);
  background: var(--color-primary);
  color: var(--color-white);
  border: none;
  cursor: pointer;
  transition: background 200ms ease;
}

[data-element="btn"]:hover {
  background: var(--color-primary-hover);
}

/* --- Layer 3: Blocks (the .testimony, .pricing-table replacement) --- */
[data-block="testimony"] {
  display: grid;
  gap: var(--spacing-4);
  padding: var(--spacing-6);
  border-radius: var(--radius-lg);
  background: var(--color-bg);
  box-shadow: var(--shadow-md);
}

[data-block="testimony"] > [data-element="card"] {
  box-shadow: none;
  background: transparent;
}

This is Italo's exact architecture-semantic tokens feed elements, elements compose into blocks-expressed in 100% standard CSS. Change --color-primary in one place, every button, card, and block updates. No build step. No @apply. No framework churn. The platform does the work.

The neon shadow example from Italo's article? One line change:

--shadow-sm: 0 0 12px oklch(0.7 0.2 260 / 0.5);

Done. Every element consuming var(--shadow-sm) updates. This is the power of the cascade, not the power of Tailwind.

The "bootstrapy" problem is structural, not cosmetic

Italo ends with a concern I want to amplify: Tailwind risks becoming what Bootstrap became-a visual signature that screams "I was built with X." Every Tailwind site starts from the same palette, the same spacing scale, the same shadow tokens. Customizing the @theme helps, but customization requires time and expertise that the "just use the defaults" pitch actively discourages.

Agencies that sell visual differentiation should be terrified of this. If your landing page looks like every other Tailwind landing page, your client paid for a template with their logo on it. Native CSS, by contrast, has no default aesthetic. Every property starts empty. The design is yours to define, not yours to override.

This is not an accident. It is a consequence of off-the-shelf design systems: they optimize for consistency within a project at the cost of differentiation between projects. And in a market where AI can generate a Tailwind landing page in thirty seconds, differentiation is the only thing left to sell.

The bottom line

Tailwind CSS was the right answer to the wrong question. The question was: "how do we make CSS productive given that CSS is missing core features?" The answer in 2017 was a utility class framework. The answer in 2026 is: "CSS is no longer missing those features."

Use custom properties for design tokens. Use @layer for cascade control. Use [data-component] selectors for reusable abstractions. Use native nesting for readability. Use :has() for parent-aware styling. Use color-mix() for derived colors. Use clamp() for fluid sizing.

Delete the build step. Delete the dependency. Delete the class-name tax.

The platform caught up. Your stack should catch up too.

References


This article was written with AI support and reviewed by the author.

Discussion

Comments live in GitHub Discussions

Each thread is keyed to the source markdown entry for this post.