Frontend work pulls in two directions. Users want a site that looks good, loads fast, and behaves predictably. Developers want code that stays readable and maintainable. Most solutions favor one side at the other's expense.
I lean on Tailwind CSS for its utility-first speed, but I take the critique from frameworks like Nuejs seriously: piles of utility classes bury the semantic structure of your HTML. This post is how I reconcile the two with CSS layers - keeping Tailwind's flexibility without giving up clean markup.
What makes a design hold up
Good design comes down to a few constraints that stop arbitrary decisions before they multiply - the antidote to the over-engineering that plagues modern frontends:
- A limited color palette - five to seven core colors with clear semantic roles (brand, neutral, success/warning/error, text). Constrained palettes keep contrast consistent and accessible.
- Typography constraints - one font stack with a clear hierarchy. Visual rhythm comes for free; users parse structure faster.
- A small set of components - a standardized library beats a sprawl of similar-but-different elements that confuse users and balloon maintenance.
- A mathematical spacing scale - systematic steps (powers of 2 work well) instead of arbitrary pixel values.
Two more things matter and cost almost nothing. Semantic HTML gives you
accessibility and SEO by default - screen readers and search engines already
understand <nav>, <article>, and <button>, so reaching for custom
components throws that away. And responsiveness is non-negotiable: mobile is
over 60% of web traffic, desktop roughly a third, tablets a sliver - though
the mix swings hard by industry (restaurants skew mobile, SaaS skews desktop).
Where Tailwind shines, and where it hurts
Tailwind's Preflight normalizes browser inconsistencies, and from that baseline you style directly in the markup with no file switching. Compression handles the long class lists in production, so the performance worry is overblown.
The real cost is readability. Utility-heavy HTML obscures its own structure, and
it quietly invites inconsistency - slightly different class combinations creep in
across components. The pain is worse in template systems like Django, where
reuse takes deliberate effort ({% include %}), than in component frameworks
like Svelte or React that encapsulate by default.
The Nuejs counter-argument
Nuejs rejects utility-first entirely,
betting on modern CSS instead:
@layer for cascade
control, custom properties
for theming, calc(),
and native nesting.
Instead of Tailwind's full reset, it normalizes minimally so semantic elements
keep their natural styling:
@layer settings { *, *::before, *::after { box-sizing: border-box; }
form { button, input, select, textarea { font: inherit; /* Match body text */ } }}It also favors semantic class names with minimal pollution - a modifier on a base component rather than a wall of utilities:
<div class="notification card"> <h3>ChitChat</h3> <p>New message</p></div>@layer components { .card { box-shadow: 0 0 2em #0001; border: var(--border); border-radius: 0.5em; padding: 1.5em; font-size: 95%;
&.notification { background: url(/img/chat.svg) 10% center no-repeat; background-size: 3rem; padding-left: 6rem; } }}And it styles off ARIA attributes rather than classes, which nudges you toward accessible markup as a side effect:
.accordion[aria-expanded="true"] { max-height: 100%;}
.accordion[aria-expanded="false"] { max-height: 0;}The tone is harsh, but the concern about long-term maintainability is fair.
Reconciling the two
I don't want to pick a side. Tailwind never forbade plain CSS - it actively
supports component extraction through @apply
and CSS nesting. So I organize the whole stylesheet with layers and use each tool
where it fits:
@import "tailwindcss";
@import "./theme.css" layer(theme);@import "./base.css" layer(base);@import "./components.css" layer(components);@import "./utilities.css" layer(utilities);
@custom-variant dark (&:where(.dark, .dark *));@plugin "@tailwindcss/forms";@plugin "@tailwindcss/typography";Four layers, each with one job: a theme, base element styles, reusable components, and custom utilities. The Forms and Typography plugins add well-tested, accessible defaults on top.
Theme
theme.css is the design system's single source of truth - colors, spacing,
typography. Defining colors in :root (with a dark @variant) and then mapping
them into @theme lets one variable swap retheme the whole app:
:root { --foreground: oklch(0.15 0.01 270); /* Rich dark */ --background: oklch(0.99 0.002 270); /* Pure white with hint */ --primary: oklch(0.55 0.18 262); /* Rich blue */ --secondary: oklch(0.6 0.14 285); /* Purple-blue */ --success: oklch(0.7 0.16 142); /* Fresh green */ --error: oklch(0.65 0.2 25); /* Vibrant red */ --warning: oklch(0.8 0.15 75); /* Warm amber */ --muted: oklch(0.55 0.03 270); /* Neutral gray */
@variant dark { --foreground: oklch(0.95 0.01 270); /* Near white with warmth */ --background: oklch(0.05 0.002 270); /* Deep dark with blue hint */ --primary: oklch(0.7 0.15 262); /* Brighter blue for contrast */ --secondary: oklch(0.72 0.12 285); /* Lighter purple-blue */ --success: oklch(0.75 0.14 142); /* Brighter green */ --warning: oklch(0.85 0.13 75); /* Brighter amber */ --error: oklch(0.7 0.18 25); /* Softer red */ --muted: oklch(0.45 0.02 270); /* Muted gray for dark mode */ }}
@theme { --color-primary: var(--primary); --color-secondary: var(--secondary); --color-success: var(--success); --color-error: var(--error); --color-warning: var(--warning); --color-foreground: var(--foreground); --color-background: var(--background); --color-muted: var(--muted);
/* Typography system */ --font-sans: Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif; --font-serif: Rockwell, "Rockwell Nova", "Roboto Slab", "DejaVu Serif", "Sitka Small", serif; --font-mono: "JetBrains Mono", ui-monospace, monospace;}The --color-* prefix matters: Tailwind generates utilities like bg-background
and text-foreground from variables named that way.
Base styles
base.css sets defaults for semantic HTML elements - background, text color,
headings, links. It keeps presentation consistent while the HTML stays meaningful:
/* Minimal base styles */html { @apply scroll-smooth;}
body { @apply bg-background text-foreground font-sans;}Components
components.css is where reusable patterns live, mixing Tailwind utilities with
custom CSS where needed:
/* Button components */.btn { @apply px-4 py-2 rounded-lg font-medium transition-colors; @apply focus:outline-2 focus:outline-offset-2 focus:outline-primary;
&.btn-primary { @apply bg-primary text-white hover:bg-primary/90; }
&.btn-secondary { @apply bg-secondary text-white hover:bg-secondary/90; }
&:disabled { @apply opacity-50 cursor-not-allowed; }}On bigger projects, split it into one file per component:
@import "./button.css";@import "./card.css";@import "./form.css";@import "./navigation.css";Utilities
utilities.css holds custom utilities for the gaps Tailwind doesn't cover:
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none;
&::-webkit-scrollbar { display: none; }}
/* Screen reader only utility */.sr-only { @apply absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap border-0; clip: rect(0, 0, 0, 0);}Because each style lives in exactly one layer and one place, choosing between plain CSS and a utility becomes a matter of context, not architecture.
Conclusion
Bootstrap is fastest when you need standard components; it gets constraining once the brand demands specifics. Tailwind buys back that flexibility at the cost of HTML readability. DaisyUI sits in the middle, and its semantic-component-plus-utility model mirrors the layered setup here.
The hybrid wins because it separates concerns: CSS owns presentation, HTML owns structure and accessibility. You get Tailwind's speed and a codebase that still reads cleanly a year later.
At its heart, web design should be about words. Words don't come after the design is done. Words are the beginning, the core, the focus.
-- Words
See also: Motherfucking Website - a satirical critique of over-engineered web development.