The task that exposes it
An SEO crawler reports "missing H1". The page's hero is a styled div:
<div class="mb-4 font-garamond text-3xl font-semibold text-cerulean-700 sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl">
Better financial plans.
</div>The fix looks purely semantic: change the tag, keep the class string byte-identical. Whether that is actually a no-op depends entirely on your Tailwind major version, because nearly every codebase also has a global element rule in its CSS entrypoint:
@tailwind base;
@tailwind components;
@tailwind utilities;
h1 { font-size: 1.875rem; font-weight: 700; } /* or @apply text-3xl font-bold */Measured outcome
Identical CSS source, identical markup <h1 class="text-6xl font-semibold">, compiled by each major and measured with getComputedStyle in Chrome:
| Tailwind | emitted .text-6xl | emitted h1 rule | computed font-size | computed font-weight |
|---|---|---|---|---|
| 3.4.17 | unlayered | unlayered | 60px (utility wins) | 600 (utility wins) |
| 4.3.3 | inside @layer utilities | unlayered | 30px (element rule wins) | 700 (element rule wins) |
Same edit: invisible in v3, and on v4 it silently collapses a 72px display heading to 30px and re-bolds it. Every responsive step (sm:/md:/lg:/xl:) dies at once, so the regression is worst at desktop widths where the utility was doing the most work.
Repro: compile @tailwind base/components/utilities + a trailing h1 {} rule with tailwindcss@3.4.17, compile @import "tailwindcss" + the same h1 {} rule with @tailwindcss/cli@4, inline each output into a page containing only that h1, and read the computed style.
Why
v3 does not use native CSS cascade layers at all. @tailwind utilities expands in place as plain rules, so this is an ordinary specificity contest and .text-6xl (0,1,0) beats h1 (0,0,1) regardless of source order.
v4 emits @layer theme, base, components, utilities; and puts utilities in the utilities layer. Per CSS Cascade 5, unlayered declarations form an implicit final layer that outranks every named layer, and layer order is consulted before specificity. So an unlayered h1 { font-size: ... } beats .text-6xl inside @layer utilities even though the class is more specific. Specificity is never reached.
The dangerous middle state
Watch for an entrypoint with mixed v3 and v4 syntax — v3 directives plus v4-only at-rules:
@tailwind base; /* v3 */
@plugin "tailwindcss/typography"; /* v4-only, inert under v3 */
h1 { @apply text-3xl font-bold; }
@theme { --breakpoint-lg: 60rem; } /* v4-only, inert under v3 */Under v3 the v4 at-rules are silently dropped, so the file looks migrated and behaves v3. Two consequences:
- Semantic-tag promotions like
div->h1are safe today and become regressions the moment someone finishes the v4 upgrade. The blast radius is every heading in the codebase, not the file you touched. - The inert
@themeblock is itself a live bug. In one real case--breakpoint-lg: 60rem / --breakpoint-xl: 75rem / --breakpoint-2xl: 88remwere declared in@themewhile the v3 JS config declared noscreensat all, so the compiled CSS carried stocklg: 1024px / xl: 1280pxand no2xlutilities existed. Verified by walkingdocument.styleSheetsforCSSRule.MEDIA_RULEand readingconditionTextfor rules whose inner selectors carry thelg\:prefix;getPropertyValue('--breakpoint-lg')on:rootcame back empty, confirming the block never compiled.
Fixes
- Preferred: wrap global element styles in
@layer base { ... }so utilities keep winning in both majors. - Or drop the global heading rules entirely and let utilities own typography, which is the v4-idiomatic answer.
- Never rely on "the class is more specific" as your safety argument when native
@layeris in play.
Verification technique worth reusing
Don't eyeball a semantic tag swap, and don't reason about specificity — diff computed styles against a same-class control in the live page, which accounts for every stylesheet, plugin and layer actually loaded:
const h = document.querySelector('h1');
const control = document.createElement('div'); // the tag you replaced
control.className = h.className; // byte-identical classes
control.textContent = h.textContent;
h.parentElement.appendChild(control); // same parent => same inherited/cascade context
const pick = (s) => ({ fontSize: s.fontSize, fontWeight: s.fontWeight, letterSpacing: s.letterSpacing,
color: s.color, lineHeight: s.lineHeight, marginTop: s.marginTop, marginBottom: s.marginBottom });
console.log({ promoted: pick(getComputedStyle(h)), control: pick(getComputedStyle(control)) });
control.remove();All fields equal means provably zero visual delta. This catches surprises a screenshot diff misses (e.g. a global letter-spacing: -0.025em that only element selectors apply), needs no visible browser window, and works on authenticated pages a crawler cannot reach.
Pair it with an SSR-level assertion, since the SEO requirement is about server HTML, not the hydrated DOM:
const html = await (await fetch(origin + path)).text();
const h1s = [...html.matchAll(/<h1[^>]*>([\s\S]*?)<\/h1>/g)];
// assert exactly 1 per pageTwo traps when auditing a route list this way:
- A logged-in browser session redirects
/loginto/home, sowaitForSelector('h1')fails and looks like the fix didn't land. Use an unauthenticatedfetchfor auth-gated routes. - If your search tool HTML-escapes its input, a codebase search for
<h1silently becomes a search for<h1and returns "no matches", which reads as "no H1 anywhere" rather than "my pattern was mangled". Establish absence against rendered HTML, not against a grep.