I have history here. Back in 2017 I was singing the praises of Bourbon, the Sass mixin library, right here on this blog. Later I wrote two posts on wiring Tailwind into Rails, and the previous version of this very site was built on Tailwind. I say all this so you know this isn’t a drive-by hot take: I have reached for the frameworks, happily, for the best part of a decade.

But when I rebuilt this site recently, I did it in plain CSS. One hand-written stylesheet, a few hundred lines, no build step, no node_modules. And the surprising part wasn’t that it was possible. It was that I didn’t miss anything. So this post is a tour of why: everything the frameworks used to do for us, and the native CSS that does it now.

A short history of the crutches

It helps to remember why each generation of tools existed, because each one was patching a specific hole in CSS at the time.

The component era: Bootstrap and friends. Around 2011, CSS had no real layout system. Grids were floats and clearfix hacks, and every browser rendered forms differently. Bootstrap (and Foundation, and later Bulma, Skeleton, Materialize and the rest) gave us a 12-column grid, normalised styling, and a shelf of prebuilt components: navbars, modals, dropdowns, carousels, most of them powered by jQuery plugins. For a while, half the internet looked like Bootstrap, because half the internet was Bootstrap.

The preprocessor era: Sass, Compass, Bourbon. CSS had no variables, no nesting, no functions, so we compiled it from something that did. Compass and Bourbon sat on top of Sass as mixin libraries, mostly generating vendor prefixes and gradient fallbacks so you didn’t have to remember the incantations. Bourbon’s little sister Neat even gave you a grid built from Sass maths, because remember, there was no grid.

The utility era: Tachyons, then Tailwind. Once layout and browsers settled down, the remaining pain was the cascade itself: naming things, specificity wars, stylesheets nobody dared delete from. Utility classes routed around all of it by putting the styling in the markup with a constrained design vocabulary.

Three eras, three different holes. And here’s the thing: the platform has since filled all three. Grid and flexbox answered Bootstrap’s layout system. Custom properties and native nesting answered Sass. Cascade layers, @scope and container queries answered the problems that pushed people to utilities. Even the jQuery component shelf has native replacements now: <dialog> is a modal, the popover attribute handles dropdowns and tooltips, <details> is an accordion, all keyboard-accessible out of the box.

Let’s go through it properly.

Vendor prefixes: the reason Bourbon existed

Half of Bourbon was mixins that spat out -webkit- and -moz- prefixed rules so you didn’t have to remember them. That whole category of tooling is obsolete. Browsers ship features unprefixed and in lockstep these days, and the old workhorses (flexbox, grid, transforms, transitions, animations) have been rock solid everywhere for years. Bourbon itself has quietly wound down, which tells you everything: it won by becoming unnecessary.

Variables, natively

We used Sass variables and framework config files to define colours and spacing in one place. Custom properties do the same job natively, and better, because they’re live in the browser rather than baked in at compile time. This site’s entire theming system is a handful of them:

[data-theme="light"] {
  --bg: #f4f2ee;
  --text: #191818;
  --accent: #d9422e;
}
[data-theme="dark"] {
  --bg: #161826;
  --text: #e9e9ed;
  --accent: rgb(255, 91, 77);
}

Flip one attribute on <html> and the whole site re-themes instantly. No rebuild, no duplicate stylesheets. And if you don’t need a manual toggle at all, light-dark() collapses both themes into single declarations that follow the visitor’s system preference:

:root { color-scheme: light dark; }
body {
  background: light-dark(#f4f2ee, #161826);
  color: light-dark(#191818, #e9e9ed);
}

Two lines per property and dark mode is done. There’s even accent-color, one declaration that themes your checkboxes, radios and progress bars to match your brand. We used to rebuild those controls from scratch just to change their colour.

Nesting, natively

The other half of the Sass pitch. It’s just in CSS now, every browser:

.site-nav {
  display: flex;

  a {
    color: var(--text);

    &:hover { color: var(--accent); }
  }
}

Between this and custom properties, the honest question is what you’re still compiling Sass for.

Colour functions

I used to keep a palette of hand-tuned greys: one for borders, one for muted text, one for captions, each needing a dark-mode twin. Now I derive them all from the text colour, and they automatically work in both themes:

.meta    { color: color-mix(in srgb, var(--text) 40%, transparent); }
.divider { border-color: color-mix(in srgb, var(--text) 12%, transparent); }

Those two lines replaced about a dozen palette entries on this site. There’s also the relative colour syntax if you want to nudge lightness or saturation from a base colour, and the oklch() colour space, which finally makes “same colour, bit lighter” behave the way your eyes expect.

Layout maths

clamp() gives you fluid type in one line, no breakpoint ladder:

h1 { font-size: clamp(1.8rem, 4vw + 1rem, 2.6rem); }

Add aspect-ratio for media, gap on flexbox as well as grid (remember margin-hacking children apart?), min() and max() inline in any value, and honestly most of the “responsive utilities” section of any framework evaporates.

Grid deserves its own mention. It was always the layout engine frameworks wrapped in column classes, but subgrid closed the last real gap: nested elements can now align to the parent’s tracks, so card contents line up across a row regardless of how long each title is.

.cards  { display: grid; grid-template-columns: repeat(3, 1fr); }
.card   { display: grid; grid-row: span 3; grid-template-rows: subgrid; }

Container queries: the one we actually wanted

Media queries respond to the viewport. But components don’t live in viewports, they live in columns and sidebars and cards. Container queries let a component respond to the space it’s actually in, which is the thing utility breakpoints (md:flex, and friends) were always approximating:

.card-holder { container-type: inline-size; }

@container (min-width: 500px) {
  .card { display: grid; grid-template-columns: 160px 1fr; }
}

Drop that card in a narrow sidebar and it stacks; put it full-width and it goes two-column. Same component, zero page-level knowledge.

Selectors grew up

:has() quietly killed a whole class of “add a helper class with JavaScript” patterns. It’s a parent selector, the thing we were told for twenty years was impossible. Style a label when its input is invalid, highlight a card containing an unread badge, adjust a layout when a sidebar exists, all without a line of JS:

label:has(+ input:invalid) { color: var(--accent); }
.layout:has(.sidebar) { grid-template-columns: 240px 1fr; }

Meanwhile :is() and :where() clean up the selector lists we used to generate with Sass loops. And :where() has zero specificity, which is the trick behind the next section.

:is(h1, h2, h3) a { color: inherit; }

Taming the cascade

A fair criticism of hand-rolled CSS was that it decayed: specificity wars, !important, stylesheets nobody dared touch. The platform answered that too. @layer lets you declare the cascade order up front, so your reset, base styles and components can’t ambush each other regardless of specificity:

@layer reset, base, components, utilities;

@layer components {
  .btn { padding: 10px 18px; border-radius: 8px; }
}

A utility declared in a later layer beats a component style even if the component selector is more specific. That’s the predictability frameworks sold, as a native one-liner. And @scope handles the other classic mess, styles leaking into nested components, by giving rules an explicit lower boundary.

Typography niceties

Small ones that used to need JavaScript libraries or careful <br> placement: text-wrap: balance evens out multi-line headings so you don’t get one orphaned word on the second line, and text-wrap: pretty does a lighter version for body text. One property each. I have balance on every heading on this site and nobody will ever notice, which is exactly the point.

h1, h2, h3 { text-wrap: balance; }

Motion, respectfully

Transitions and keyframes have been native forever, but two newer bits round it out. @property lets you register a custom property with a type, which means you can transition things that were never animatable before, like a gradient’s angle. And respecting people’s motion preferences is one query:

@media (prefers-reduced-motion: reduce) {
  .rise { animation: none; }
}

That exact rule is in this site’s stylesheet. Accessibility features frameworks bolted on as plugins are just… media queries.

The nearly-there shelf

Being honest about the frontier. Scroll-driven animations (animating on scroll position with no JS) and anchor positioning (tooltips and popovers that position themselves relative to a trigger) are shipping across browsers now but I’d still check support before betting a client project on them. View transitions between pages are in the same bucket. The direction of travel is obvious though: every one of these is another JavaScript library on borrowed time.

So are Bootstrap and Tailwind bad now?

Honest verdicts, one at a time.

Bourbon, Compass and the prefix mixins: genuinely done. Their entire purpose evaporated when browsers stopped needing prefixes. Bourbon itself has wound down, and I’d treat any project still compiling it as carrying a small archaeology exhibit.

Bootstrap: not needed, but not pointless. Nobody needs its grid (that’s display: grid now) or its jQuery-era components (<dialog>, popover, <details>). But “a shelf of decent-looking, roughly accessible components I don’t have to design” is still a real value proposition for internal tools, admin panels and back-offices where design isn’t the point. If you reach for Bootstrap in 2026, reach for it as a component library, with the knowledge that every structural thing underneath it is standard CSS you could own. The same goes for Foundation and Bulma, though both are far quieter these days.

Sass: mostly done. Variables, nesting, colour functions and maths are all native. What’s left is loops, mixins and partials-based organisation, and @layer plus plain multiple files covers most of that. If you removed Sass from a project today, I suspect most people would take a week to notice.

Tailwind: not needed, still reasonable. It remains a great choice for teams that want a shared vocabulary, for prototyping at speed without naming things, and for design systems where consistency across dozens of developers matters more than file size. Tellingly, Tailwind v4 rebuilt itself on everything above: its config is now CSS custom properties in an @theme block, its engine leans on native cascade features. Even the frameworks agree that modern CSS is the foundation.

The pattern across all of them is the same. Bootstrap existed because CSS had no layout system. Bourbon existed because of vendor prefixes. Sass existed because CSS had no variables or nesting. Utility frameworks caught on partly because the cascade was untameable and component-level responsiveness didn’t exist. Every one of those holes is filled. What’s left is preference and workflow, and those are perfectly fair reasons, but they’re different reasons, and they should be chosen, not defaulted into.

What I’d actually suggest

Next side project, before you npm install anything, try starting with a single CSS file and this recipe:

  • custom properties for your colours and spacing (your “config”);
  • light-dark() or a data-theme attribute for dark mode;
  • nesting for structure;
  • color-mix() to derive your greys instead of picking them;
  • clamp() for type, grid and gap for layout;
  • @layer if the file grows past a few hundred lines;
  • text-wrap: balance on headings, prefers-reduced-motion on anything that moves.

My bet is you’ll get further than you expect, the file will stay small enough to hold in your head, and there will be nothing to update, purge or migrate in three years.

This site is my proof. The old build had a node_modules folder for the CSS alone. The new one is a single file I can read top to bottom with a cuppa, and every trick in this post is somewhere in it.