---
title: "08 — Modern CSS — Stop Naming Things, Start Composing Intent"
uid: modern-css
tags: ["css", "utility-first", "css-in-js", "responsive", "tailwind", "roadmap:frontend"]
excerpt: "Modern CSS is one shift: compose intent at the element, stop naming classes, and let a build step generate only the CSS the page actually uses."
date: 2026-08-12T18:35:11+0000
source: https://www.aveshina.my.id/en/blog/modern-css
---

"A pile of unrelated tools" was my modern-CSS read, and it missed the single shift underneath. The shift: **The shift isn't a framework. It's giving up on naming classes and writing CSS rules by hand, and instead composing intent at the element — then letting a build step generate only the CSS the page actually uses** [1].

The whole story is one tradeoff, worth seeing up front:

```figure
<svg viewBox="0 0 720 300" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="Two ways to style an element. On the left, the old way: a stylesheet holds named rules like .card, .card-title, .btn; the markup references the names; the CSS file ships every rule whether or not it is used. On the right, the modern way: small utility classes like flex, gap-4, text-lg sit directly on the element; a build step reads the markup and emits only the CSS actually referenced, a tiny file. The tradeoff is labeled beneath: readable stylesheet vs readable markup, no dead CSS either way.">
  <defs>
    <marker id="carrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
      <path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>
    </marker>
  </defs>
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">

    <!-- LEFT: old way -->
    <text x="160" y="28" font-size="12" font-weight="700" fill="#422006" text-anchor="middle">Hand-written stylesheet</text>
    <rect x="40" y="44" width="240" height="92" rx="8" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5"/>
    <text x="56" y="66" font-size="10" font-family="ui-monospace, monospace" fill="#422006">.card { padding: 1rem; }</text>
    <text x="56" y="82" font-size="10" font-family="ui-monospace, monospace" fill="#422006">.card-title { font-size: 1.125rem; }</text>
    <text x="56" y="98" font-size="10" font-family="ui-monospace, monospace" fill="#422006">.btn { display: inline-flex; }</text>
    <text x="56" y="118" font-size="9.5" fill="#64748b" font-style="italic">ships every rule, used or not</text>

    <rect x="40" y="150" width="240" height="40" rx="8" fill="#fffbeb" stroke="#ca8a04" stroke-width="1.2"/>
    <text x="160" y="175" font-size="11" font-family="ui-monospace, monospace" fill="#422006" text-anchor="middle">&lt;div class="card"&gt; … &lt;/div&gt;</text>

    <!-- RIGHT: modern way -->
    <text x="560" y="28" font-size="12" font-weight="700" fill="#1e1b4b" text-anchor="middle">Utility-first + build step</text>
    <rect x="440" y="44" width="240" height="92" rx="8" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="456" y="66" font-size="10" font-family="ui-monospace, monospace" fill="#1e1b4b">p-4  →  padding: 1rem;</text>
    <text x="456" y="82" font-size="10" font-family="ui-monospace, monospace" fill="#1e1b4b">text-lg  →  font-size: 1.125rem;</text>
    <text x="456" y="98" font-size="10" font-family="ui-monospace, monospace" fill="#1e1b4b">inline-flex  →  display: inline-flex;</text>
    <text x="456" y="118" font-size="9.5" fill="#64748b" font-style="italic">generated from markup · no dead CSS</text>

    <rect x="440" y="150" width="240" height="40" rx="8" fill="#eef2ff" stroke="#6366f1" stroke-width="1.2"/>
    <text x="560" y="175" font-size="10" font-family="ui-monospace, monospace" fill="#1e1b4b" text-anchor="middle">&lt;div class="p-4 text-lg"&gt; … &lt;/div&gt;</text>

    <!-- build arrow -->
    <line x1="560" y1="190" x2="560" y2="222" stroke="#6366f1" stroke-width="1.5" marker-end="url(#carrow)"/>
    <text x="560" y="244" font-size="10" font-weight="700" fill="#1e1b4b" text-anchor="middle">build · purge</text>
    <rect x="470" y="252" width="180" height="32" rx="6" fill="#dcfce7" stroke="#16a34a" stroke-width="1.2"/>
    <text x="560" y="272" font-size="10" font-family="ui-monospace, monospace" font-weight="700" fill="#052e16" text-anchor="middle">tiny optimized .css</text>

    <!-- tradeoff label -->
    <text x="360" y="300" font-size="10.5" fill="#64748b" text-anchor="middle" font-style="italic">tradeoff: readable stylesheet ↔ readable markup</text>
  </g>
</svg>
```

Once I saw it as one shift, the bag of tools fell into a single lineage: each generation tried to kill the same two pains — **the global namespace of class names, and the dead CSS that ships when rules are written by hand.**

## The pains that drove everything

Hand-written CSS has two structural problems, and every modern approach is a different answer to them [1][2].

- **The global namespace.** A class name like .title lives in one flat global scope. Two developers on the same project both write .title, and whichever stylesheet loads last wins. Naming conventions (BEM — block__element--modifier) were the first answer: discipline the humans so collisions don't happen. It works, but it pushes the cost into my head — I spend real effort inventing names that won't clash and then keeping them consistent.
- **Dead CSS.** I write a rule for .profile-card, ship it, then six months later delete the markup. The rule stays in the stylesheet forever because nobody dares remove it — maybe something else still uses it. Real codebases ship kilobytes of CSS that matches nothing. The selector-to-element mapping lives only in a human's memory, and it rots.

Every modern approach is an attempt to delete one or both of those costs.

## Responsive: media queries and mobile-first

Before the tooling, the language itself leveled up. **Media queries** let a stylesheet branch on conditions of the viewport — @media (min-width: 768px) { ... } applies only when the screen is at least that wide [3]. The mental move that goes with them is **mobile-first**: write the small-screen styles as the base, then layer min-width queries for larger screens. Each breakpoint only *adds*, never undoes, so the cascade stays predictable. (The opposite — desktop-first with max-width queries undoing desktop rules — produces a fighting-the-cascade feeling I learned to avoid.)

That's a language feature, not a framework — it composes with any approach below. It belongs here because responsive design is what made hand-written CSS really hurt: every component suddenly needs three or four variants, the stylesheet balloons, and dead CSS gets worse. Tooling became worth the cost precisely because responsive multiplied the CSS we write.

## CSS-in-JS: scope by generating the names

The first tooling answer was **CSS-in-JS**. Instead of me naming classes, the library generates a unique class name at build (or runtime) for each style and scopes it to the component. The global namespace disappears by construction — two .titles become .title-3kf9a and .title-7b2xc, so they can't collide [2]. Dead CSS gets solved too: styles live inside the component file, so when I delete the component, its styles go with it. The cost is runtime overhead (older libraries injected styles on render) or build complexity (newer ones extract at compile time), plus tight coupling to the JS runtime.

## Utility-first: scope by not naming at all

Tailwind took the opposite route. **Instead of generating unique names for hand-written rules, don't name things at all** [4]. Compose the element's appearance directly from small, single-purpose utility classes — p-4 for padding, text-lg for size, flex for layout — each a thin wrapper over one CSS declaration. The element's intent lives on the element. There's no stylesheet to hunt through, and no name to collide.

The killer feature, though, isn't the utilities — it's the **build step.** Tailwind scans the markup, finds only the utility classes actually present, and generates a stylesheet containing exactly those declarations and nothing else [4][5]. Dead CSS is structurally impossible: a utility that isn't in the markup isn't in the output. The shipped CSS for a whole app is typically a few kilobytes.

```figure
<svg viewBox="0 0 720 220" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="How Tailwind's purge works. Left box is the project source: HTML and component files containing markup with utility classes like p-4, text-lg, flex, gap-2 scattered on elements, plus a much larger set of utilities defined in Tailwind's catalog that are never referenced. A build arrow labeled scan markup leads to the right box: the output CSS file containing only declarations for p-4, text-lg, flex and gap-2. The unreferenced utilities from the catalog are dropped entirely.">
  <defs>
    <marker id="tarrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
      <path d="M0,0 L10,5 L0,10 z" fill="#6366f1"/>
    </marker>
  </defs>
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">

    <!-- source -->
    <rect x="30" y="30" width="280" height="160" rx="8" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1.5"/>
    <text x="170" y="52" font-size="12" font-weight="700" fill="#1e1b4b" text-anchor="middle">Project source (markup)</text>
    <text x="46" y="76" font-size="10" font-family="ui-monospace, monospace" fill="#1e1b4b">&lt;div class="p-4 text-lg"&gt;</text>
    <text x="46" y="94" font-size="10" font-family="ui-monospace, monospace" fill="#1e1b4b">&lt;ul class="flex gap-2"&gt;</text>
    <text x="46" y="118" font-size="9.5" fill="#64748b" font-style="italic">Tailwind catalog defines thousands</text>
    <text x="46" y="132" font-size="9.5" fill="#64748b" font-style="italic">of utilities; most are never used</text>
    <text x="46" y="158" font-size="10" font-family="ui-monospace, monospace" fill="#94a3b8">rotate-90 · blur-sm · ...</text>
    <text x="46" y="174" font-size="10" font-family="ui-monospace, monospace" fill="#94a3b8">outline-dotted · ...</text>

    <!-- arrow -->
    <line x1="316" y1="110" x2="396" y2="110" stroke="#6366f1" stroke-width="2" marker-end="url(#tarrow)"/>
    <text x="356" y="100" font-size="10" font-weight="700" fill="#6366f1" text-anchor="middle">scan</text>
    <text x="356" y="126" font-size="9.5" fill="#475569" text-anchor="middle">purge</text>

    <!-- output -->
    <rect x="410" y="30" width="280" height="160" rx="8" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
    <text x="550" y="52" font-size="12" font-weight="700" fill="#052e16" text-anchor="middle">Shipped CSS</text>
    <text x="426" y="78" font-size="10" font-family="ui-monospace, monospace" fill="#052e16">.p-4 { padding: 1rem; }</text>
    <text x="426" y="96" font-size="10" font-family="ui-monospace, monospace" fill="#052e16">.text-lg { font-size: 1.125rem; }</text>
    <text x="426" y="114" font-size="10" font-family="ui-monospace, monospace" fill="#052e16">.flex { display: flex; }</text>
    <text x="426" y="132" font-size="10" font-family="ui-monospace, monospace" fill="#052e16">.gap-2 { gap: 0.5rem; }</text>
    <text x="426" y="168" font-size="9.5" fill="#64748b" font-style="italic">only utilities found in markup —</text>
    <text x="426" y="182" font-size="9.5" fill="#64748b" font-style="italic">no dead CSS possible</text>
  </g>
</svg>
```

Responsive design falls out for free in the same model. Because utilities are mobile-first by default, the breakpoint variants are just prefixes: md:flex means "flex from the md breakpoint up." The breakpoint cascade reads left to right on the element (block md:flex p-4 lg:p-8) instead of being scattered across a stylesheet. I stopped context-switching between markup and CSS; the layout decisions live where the element lives.

## The honest tradeoff

This isn't free, and the reason it took me a while to adopt is visible in the markup: **class lists get long and ugly.** <div class="flex items-center gap-4 rounded-lg bg-white p-4 text-sm shadow md:flex-row md:p-6"> is real, and it's less "readable" in the traditional sense than a <div class="card"> with the rules hidden in a stylesheet.

The reframing: *readable where?* The hand-written stylesheet moves complexity into a separate file I have to keep in sync. Utility-first keeps it in the markup, so there's only one place to look — no jumping to find what .card does. I traded stylesheet-readability for markup-readability and zero dead CSS. For CSS that's mostly layout, spacing, color, and type — which is most of what I write — that's a trade I'll make every time [5].

Where I reach past it: genuinely complex styling with lots of state, animation, or theming that doesn't map to utilities. The two coexist — utility-first for layout and spacing, escape hatches (@apply, or a plain CSS module) for the rare cases that need them.

## How I use this

The habit these notes left me with is a default I didn't have: **reach for utility-first unless there's a specific reason not to.** Most of what I style — spacing, layout, responsive, color, type — is exactly what utilities model well, and the purge keeps shipped CSS as small as it can be. The mental load I used to spend inventing and consistent-keeping class names is just gone. That's the real win — not the syntax, but the naming problem I no longer have to solve.

## References

[1] Stephanie Eckles, "Modern CSS Solutions for Old CSS Problems," moderncss.dev, 2024. [Online]. Available: [https://moderncss.dev/](https://moderncss.dev/)

[2] Google, "Learn CSS," web.dev, 2024. [Online]. Available: [https://web.dev/learn/css](https://web.dev/learn/css)

[3] Mozilla, "Using media queries," MDN Web Docs, 2024. [Online]. Available: [https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries)

[4] Tailwind Labs, "Tailwind CSS — Rapidly build modern websites without ever leaving your HTML," 2024. [Online]. Available: [https://tailwindcss.com/](https://tailwindcss.com/)

[5] Tailwind Labs, "Optimizing for production," Tailwind CSS Documentation, 2024. [Online]. Available: [https://tailwindcss.com/docs/optimizing-for-production](https://tailwindcss.com/docs/optimizing-for-production)

```quiz
Q: What are the two structural pains of hand-written CSS that every modern approach tries to solve?
- The cascade and specificity wars only
- The global namespace of class names, and dead CSS that ships when rules are written by hand
- Browser compatibility and vendor prefixes
correct: 1
explain: Every modern approach (BEM, CSS-in-JS, utility-first) is an answer to the global-namespace problem and the dead-CSS problem. Specificity and vendor prefixes are real issues but not the structural driver.

Q: CSS-in-JS solves the global namespace by…
- forcing developers to use BEM naming
- generating a unique class name per style and scoping it to the component
- moving all styles into a single shared file
correct: 1
explain: The library generates unique class names (e.g. .title-3kf9a) at build or runtime, so two hand-written .title rules can't collide.

Q: Tailwind's purge/build step produces a stylesheet that contains…
- every utility in Tailwind's catalog, for safety
- only the utility classes actually referenced in the markup
- one class per component, named after the component
correct: 1
explain: The build scans the markup and emits declarations only for the utilities found there. Utilities not referenced in markup are dropped, so no dead CSS ships.

Q: In mobile-first responsive CSS, you write the small-screen styles as the base, then…
- use max-width queries to undo them for larger screens
- use min-width queries that only add styles for larger screens
- write separate stylesheets per device and load them conditionally
correct: 1
explain: Mobile-first uses min-width media queries that layer on additions at each breakpoint. The cascade only ever adds, never undoes, so it stays predictable.

Q: The honest tradeoff of utility-first CSS is…
- larger shipped CSS files
- long, "ugly" class lists in the markup, traded for readability there and no dead CSS
- it cannot do responsive design
correct: 1
explain: Complexity moves into the markup (long class lists), but you gain a single source of truth on the element and zero dead CSS. It's a readability tradeoff, not a capability gap.
```
