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:
-
Naming things is hard. No more arguing whether this is a
.card,.container,.panel, or.box. Utility classes sidestep naming entirely. -
Design tokens were inaccessible.
#876876means nothing.bg-red-500means something. A constrained palette of values kept teams consistent. - CSS had no reusable primitives. Functions, mixins, nesting-these required Sass or PostCSS. Tailwind bundled the preprocessor and the design system in one package.
- 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→ CSSif()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. Noclass="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.cardclass. Thedata-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
@layerdirectives, the@applyescape hatch, thetheme()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
- Italo Baeza Cabrera, "Is it time to bury Tailwind CSS?"
- CSS Custom Properties
-
CSS Cascade Layers (
@layer) - CSS Nesting
-
CSS
:has()selector -
CSS
color-mix()function - W3C CSS Functions and Mixins Module
-
CSS
if()function
This article was written with AI support and reviewed by the author.