Tailwind vs. CSS: 139 Components, One 133-Line Stylesheet
139 components, 881 classNames, one 133-line stylesheet: the exact 40 lines of hand-written CSS a Tailwind codebase still needs, and why.
Tailwind CSS composes styles from small, single-purpose utility classes written directly in your markup; plain CSS gives you full control over selectors and the cascade but requires you to name, write, and maintain every rule by hand. The trade isn't style versus substance — it's where the naming and dead-code problems live. This storefront's 139 components run on one 133-line stylesheet, and most of those lines aren't styling at all, which is the cleanest evidence for when each one wins.
The actual difference
| Tailwind (utility-first) | Vanilla CSS | |
|---|---|---|
| Where a style lives | Inline in className, next to the markup it affects | A separate .css file, referenced by a selector |
| Naming | None needed — the utility name is the style | You invent and maintain a convention (BEM, etc.) |
| Consistency | Enforced by a fixed token scale (@theme) | Enforced only by discipline and code review |
| Dead code | The build scans your templates and ships only classes actually used | Nothing removes an unused rule automatically |
| Cascade/specificity fights | Mostly sidestepped — every utility is single-property, so conflicts resolve by source order, not specificity wars | Fully exposed; a stray #id selector can out-rank everything |
| Responsive & dark-mode variants | Composed inline: sm:flex, dark:bg-gray-900 | Hand-written media queries or a class toggle |
| Truly dynamic, runtime-computed values | No good native answer — falls back to inline style or an arbitrary-value class | The native use case — this is what CSS custom properties and inline styles were built for |
Neither column is "no CSS." Tailwind still compiles to CSS, still respects the cascade, and a developer who doesn't understand specificity will still get surprised by it. The difference is where you spend the effort: naming and pruning selectors, or composing from a constrained set someone else maintains.
What a real Tailwind codebase's CSS footprint actually looks like
This site has exactly one hand-written stylesheet, globals.css — 133 lines total. Strip out the @theme token definitions (the color scale, font variable, breakpoints — configuration, not styling) and what's left is about 40 lines, and every one of them exists for something Tailwind's utility set genuinely does not provide:
/* The one layout rule this whole site needed a hand-written class for:
a 7-breakpoint max-width + padding table that doesn't match any
built-in .container behavior exactly. */
.container {
width: 100%;
margin-inline: auto;
padding-inline: 1rem;
}
@media (min-width: 1440px) {
.container {
padding-inline: 4rem;
}
}
/* … five more breakpoints, each changing max-width or padding independently */
@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-100%); }
}
@layer utilities {
/* No Tailwind utility hides a scrollbar across both engines */
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { scrollbar-width: none; }
}
That's the entire list: a container whose exact per-breakpoint padding table doesn't line up with a single utility, a @keyframes block (Tailwind has no built-in marquee), and a pseudo-element selector (::-webkit-scrollbar) that utility classes can't target at all. Nothing in that file is a component style — no .card, no .button, no .hero. Across 139 .tsx files and 881 separate className= usages, the component-level styling is 100% utility classes, sorted on save by prettier-plugin-tailwindcss so class order never becomes a second thing to review.
Where this codebase still reaches for a real style attribute
Utilities cover static styling — they don't cover values only known at runtime. 21 files in this codebase (55 occurrences) use an inline style={{}} instead of a class, and every one is the same shape: a value computed from state or props, not a design decision.
// src/components/molecules/FaqItem.tsx — an accordion's open/close height.
// No Tailwind utility can express "0fr when closed, 1fr when open" for a
// value that flips per-item, per-render.
<div style={{ gridTemplateRows: open ? "1fr" : "0fr" }}>
// src/components/molecules/TemplateGallery.tsx — a carousel's slide offset,
// a different number for every index, computed, not authored.
<div style={{ transform: `translateX(-${index * 100}%)` }}>
This is the honest failure mode of a utility-first system: a class name has to exist before the browser sees it, so a value that only exists at render time (an index, a boolean, a per-item brand color pulled from a data array) has nowhere to go as a utility — Tailwind's arbitrary-value syntax (bg-[#e64c18]) only works when the value is a literal string the build's scanner can see at compile time, not one interpolated from a variable. The fix in both cases above isn't a workaround; it's using the tool CSS has always had for this — the style attribute — instead of fighting the utility system into doing something it isn't for.
Why the token scale matters more at scale than in a demo
The difference between the two approaches is easy to underrate on a small project and hard to miss on a large one. This catalog spans 111 products across landing, shop, and admin categories, each with its own product page, card, and gallery — all drawing color, spacing, and type from the same 12-line @theme block shown above. Add a 112th product tomorrow and its page reaches for bg-primary, text-gray-700, border-stroke — the same tokens every other page already uses, with no new stylesheet to write and no existing one to hunt through to find "the blue we use for CTAs."
Vanilla CSS gives you the identical outcome only if you build that discipline yourself: a documented set of custom properties, enforced by convention and code review rather than by the build refusing to generate anything outside the scale. Nothing stops a hand-written stylesheet from accumulating a second, slightly different blue three files away from the first — Tailwind's utility set structurally can't drift that way, because there's only one --color-primary to reach for.
The gap shows up again with dark mode. This codebase's convention is that "new styled elements need dark: variants" — containers get dark:bg-gray-900, borders get dark:border-gray-700 — written directly on the same className as the light-mode utility. The one component that doesn't follow this, the shared Button atom, is a documented, deliberate exception: it has no dark: variants, so nine call sites patch dark-mode classes in from outside. That's the version of the "one place to change it" property vanilla CSS doesn't give you automatically — colocated dark: variants make it a two-second diff to check on review; a separate dark-theme stylesheet somewhere else is one more file to remember exists.
Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
| Two utility classes conflict, expecting the "more specific" one to win | Both are single classes with equal specificity — whichever is later in the compiled stylesheet wins, not whichever looks more specific in your JSX | Keep base and variant strings separate and always spread overrides last in the template literal |
A dynamic value passed into an arbitrary-value class: `bg-[${color}]` | The build's static scanner can't see an interpolated string, so the class is never generated — silently, no error | Use style={{ backgroundColor: color }} for anything not known until runtime |
Writing a new .css file per component "to stay organized" | Splits the style from the markup it affects — the opposite of what utility classes buy you, and now there are two files to check on every review | Keep it in className unless the value is genuinely dynamic |
| Assuming a utility framework means zero custom CSS | A @keyframes block, a ::-webkit-scrollbar selector, and a non-standard breakpoint table all still need hand-written rules | Keep vanilla CSS for the handful of things utilities structurally can't reach — that's still under 1% of this project's total styling surface |
Fighting Tailwind's cascade by adding !important | Papers over a source-order problem instead of fixing it | Reorder classes so the override comes later, or split base/variant cleanly (see Tailwind Button) |
Frequently asked questions
Isn't Tailwind just inline styles with extra steps?
No — the difference is the constrained, shared scale behind it. An inline style is an arbitrary value only that one element uses; a Tailwind utility comes from a fixed token set (@theme's color/spacing scale), gets responsive and dark-mode variants for free, and gets purged from the build if nothing uses it. Inline styles get none of that.
Do I still need to know CSS to use Tailwind well?
Yes. The cascade, specificity, the box model, and flex/grid layout are exactly as real under Tailwind as under hand-written CSS — utilities are still CSS, just pre-named. A census of how this site actually uses grid makes the point concrete: 19 files, three utilities, and not a single col-span-*. What Tailwind removes is inventing selector names and hunting down which stylesheet a rule lives in.
Does Tailwind bloat the HTML?
The markup gets longer — a className string can run to a dozen tokens. What it removes is the CSS file you'd otherwise be cross-referencing to know what those tokens do, and the dead selectors that accumulate in a hand-written stylesheet nobody prunes.
When should I write plain CSS in a Tailwind project instead of reaching for a utility?
Three real cases, all present in this codebase: keyframe animations, pseudo-element selectors utilities can't target (::-webkit-scrollbar), and a hand-tuned responsive table that doesn't line up with the built-in scale. Everything else — including runtime-dynamic values — is either a utility or an inline style, never a new stylesheet.
Templates in this post
ASoc Cortex (an AI agency site), ASoc Cover (an insurance company template) and ASoc Echo (an AI chatbot landing page) all ship on the exact stylesheet described above — one globals.css, zero component-level CSS files.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the button atom's own class list, see Tailwind Button; for the @theme token scale these utilities draw from, see 41 Design Tokens; for how Sass compares on the same axis, see Sass vs Tailwind.
