# Heading Sizes Descend with Level

**MUST** · **ID:** `content-heading-size-descends` · **Category:** content
**Source:** [jakubkrehel](https://github.com/jakubkrehel/skills)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/content-heading-size-descends

> MUST: Pick the heading tag from the document outline and the size from a DESCENDING step of the type scale — a lower level must never render larger than a higher one on the same page. Never reach for a tag because it "looks right". Adjacent levels may share a size at the small end if weight or spacing keeps them distinct; a line that must be the loudest thing on the card without owning a rank is a `<p>` at display size. Distinct from `content-rams-heading-levels`, which is about SKIPPING levels (a11y outline) — here the tags are sequential and lint passes, but the render contradicts them.

Map every heading level to a descending step of the scale so a lower level never renders larger than a higher one

This is a different failure from content-rams-heading-levels, and worth separating carefully. That rule is about SKIPPING levels — h1 straight to h3 — which lint catches and WCAG 1.3.1 names. This one is the inverse-SIZE failure, and every tag in it is sequential, so the lint passes: an `<h2>` set at `text-base` because it looked right in the card, wrapping an `<h3>` at `text-2xl` because that was the line the designer wanted read first. The tags are legal; the render contradicts them. A screen reader navigates the outline and hears "Revenue up 12%" as subordinate to "Q3 report"; the page says the opposite, in twice the pixels. Whenever a child level renders larger than its parent, the tag was picked for its default size instead of from the document structure. Take the tag from the outline, take the size from a descending step of the scale, and when a line genuinely needs to be the loudest thing on the card without owning a rank, make it a `<p>` at display size — it can be as big as it likes and it stays out of the outline entirely.

## Bad — do not do this

`content-heading-size-descends-bad`

```tsx
/**
 * Bad: the tags are sequential (h2 → h3, nothing skipped), so the usual heading
 * lint passes. The SIZES run backwards: the h2 was set at `text-base` because it
 * "looked right" in the card, and the h3 inside it is `text-2xl` because that is
 * the line the designer wanted people to read first.
 *
 * The outline panel on the right is what a screen reader navigates by. Compare it
 * with the card: the two orders disagree.
 *
 * (The heading tags are rendered as labelled divs so this demo does not inject
 * headings into the page's own outline. The class names are the real ones.)
 */

export function HeadingSizeDescendsBad() {
  return (
    <div className="w-full space-y-4 lg:grid lg:grid-cols-2 lg:gap-4 lg:space-y-0">
      <div className="rounded-lg border border-destructive/40 bg-card p-4">
        <p className="mb-3 text-xs font-medium text-destructive">What the eye sees</p>

        {/* <h2 className="text-base"> */}
        <div className="text-base font-medium text-muted-foreground">Q3 report</div>

        {/* <h3 className="text-2xl"> — a level DOWN, rendered twice the size */}
        <div className="mt-1 text-2xl font-semibold text-foreground">Revenue up 12%</div>

        <p className="mt-2 text-sm text-muted-foreground">
          Growth came almost entirely from renewals in the enterprise tier.
        </p>
      </div>

      <div className="rounded-lg border border-border bg-muted p-4">
        <p className="mb-3 text-xs font-medium text-muted-foreground">
          What the screen reader announces
        </p>
        <ol className="space-y-2 font-mono text-xs text-foreground">
          <li className="flex items-baseline gap-2">
            <span className="rounded border border-border bg-card px-1 py-0.5 text-muted-foreground">
              h2
            </span>
            <span>Q3 report</span>
            <span className="ml-auto text-muted-foreground">16px</span>
          </li>
          <li className="flex items-baseline gap-2 pl-5">
            <span className="rounded border border-border bg-card px-1 py-0.5 text-muted-foreground">
              h3
            </span>
            <span>Revenue up 12%</span>
            <span className="ml-auto text-destructive">24px</span>
          </li>
        </ol>
        <p className="mt-3 text-xs text-destructive">
          The outline says &ldquo;Revenue up 12%&rdquo; is subordinate to &ldquo;Q3 report&rdquo;.
          The page says the opposite, in 8 extra pixels. A child level rendering larger than its
          parent means the tag was picked for its default size, not from the outline.
        </p>
      </div>
    </div>
  );
}
```

## Good — do this

`content-heading-size-descends-good`

```tsx
/**
 * Good: the tag comes from the document outline, the size comes from a descending
 * step of the type scale. h2 (24px) > h3 (16px), and the visual order now agrees
 * with the order a screen reader reads out.
 *
 * The "big line" the designer wanted is still there — it is just not a heading. A
 * display-size `<p>` carries the emphasis without claiming a rank in the outline.
 *
 * (The heading tags are rendered as labelled divs so this demo does not inject
 * headings into the page's own outline. The class names are the real ones.)
 */

export function HeadingSizeDescendsGood() {
  return (
    <div className="w-full space-y-4 lg:grid lg:grid-cols-2 lg:gap-4 lg:space-y-0">
      <div className="rounded-lg border border-border bg-card p-4">
        <p className="mb-3 text-xs font-medium text-success">What the eye sees</p>

        {/* <h2 className="text-2xl"> — highest level on the card, largest step */}
        <div className="text-2xl font-semibold text-foreground">Q3 report</div>

        {/* Not a heading at all: a display-size paragraph. It can be as loud as it likes. */}
        <p className="mt-1 text-xl font-medium text-muted-foreground">Revenue up 12%</p>

        {/* <h3 className="text-base"> — one level down, one step down the scale */}
        <div className="mt-4 text-base font-medium text-foreground">Enterprise renewals</div>

        <p className="mt-1 text-sm text-muted-foreground">
          Growth came almost entirely from renewals in the enterprise tier.
        </p>
      </div>

      <div className="rounded-lg border border-border bg-muted p-4">
        <p className="mb-3 text-xs font-medium text-muted-foreground">
          What the screen reader announces
        </p>
        <ol className="space-y-2 font-mono text-xs text-foreground">
          <li className="flex items-baseline gap-2">
            <span className="rounded border border-border bg-card px-1 py-0.5 text-muted-foreground">
              h2
            </span>
            <span>Q3 report</span>
            <span className="ml-auto text-success">24px</span>
          </li>
          <li className="flex items-baseline gap-2 pl-5">
            <span className="rounded border border-border bg-card px-1 py-0.5 text-muted-foreground">
              h3
            </span>
            <span>Enterprise renewals</span>
            <span className="ml-auto text-success">16px</span>
          </li>
        </ol>
        <p className="mt-3 text-xs text-success">
          Sizes descend with the level, so the outline and the page tell the same story. The subtitle
          is loud and still not in the list — because it is a <code>&lt;p&gt;</code>, styled with
          CSS, not an <code>h3</code> borrowed for its size.
        </p>
      </div>
    </div>
  );
}
```

## References

- [jakubkrehel — better-typography SKILL.md](https://raw.githubusercontent.com/jakubkrehel/skills/main/skills/better-typography/SKILL.md)
- [jakubkrehel — spacing-and-sizing.md](https://raw.githubusercontent.com/jakubkrehel/skills/main/skills/better-typography/spacing-and-sizing.md)
- [MDN — Heading elements](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/Heading_Elements)
