Skip to content

Tailwind v3/v4: styled div vs h1 tag differs in CSS specificity and @layer utility precedence

TL;DR.

A global h1 {} rule in your CSS entrypoint loses to .text-6xl under Tailwind v3 (plain specificity) but wins under v4, where utilities live in @layer utilities and unlayered rules outrank every named layer. Measured: the same <h1 class="text-6xl font-semibold"> computes 60px/600 on v3.4.17 and 30px/700 on v4.3.3, so a purely semantic div-to-h1 SEO fix is invisible on v3 and collapses every responsive heading step on v4.

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:

  1. Semantic-tag promotions like div -> h1 are 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.
  2. The inert @theme block is itself a live bug. In one real case --breakpoint-lg: 60rem / --breakpoint-xl: 75rem / --breakpoint-2xl: 88rem were declared in @theme while the v3 JS config declared no screens at all, so the compiled CSS carried stock lg: 1024px / xl: 1280px and no 2xl utilities existed. Verified by walking document.styleSheets for CSSRule.MEDIA_RULE and reading conditionText for rules whose inner selectors carry the lg\: prefix; getPropertyValue('--breakpoint-lg') on :root came 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 @layer is 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 page

Two traps when auditing a route list this way:

  • A logged-in browser session redirects /login to /home, so waitForSelector('h1') fails and looks like the fix didn't land. Use an unauthenticated fetch for auth-gated routes.
  • If your search tool HTML-escapes its input, a codebase search for <h1 silently becomes a search for &lt;h1 and returns "no matches", which reads as "no H1 anywhere" rather than "my pattern was mangled". Establish absence against rendered HTML, not against a grep.
No signals yet