Skip to content

a11y: allow overriding heading tag (as prop) on title-bearing components #371

@bntvllnt

Description

@bntvllnt

Problem

Many components hardcode a specific heading tag (<h2>, <h3>, <h4>) for their title slot. When these components are composed into a page, the surrounding heading hierarchy is unknown to the component, so the rendered DOM can skip levels (e.g. h1h3).

This breaks WCAG 1.3.1 / 2.4.6 (Info & Relationships, Headings & Labels) and Lighthouse heading-order audit:

Heading elements are not in a sequentially-descending order.

The component author can't fix this from the consumer side without forking, monkey-patching, or wrapping with a sr-only bridge heading — all leaky workarounds.

Proposal

Add an optional as prop (or headingLevel) to every component that renders a user-supplied title heading. Default stays at the current level for backward compatibility.

type HeadingTag = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";

<ProfileSection as="h1" dict={...} />
<FAQ as="h3" items={...} />
<ContentIntro titleAs="h1" tocLabelAs="h2" title={...} />

Implementation sketch (per component):

function ProfileSection({ as: Heading = "h2", dict, ... }: Props) {
  return (
    <div>
      <Heading className="font-semibold text-xl">{dict.profile.name}</Heading>
      ...
    </div>
  );
}

For components with multiple headings (e.g. ContentIntro has a title + a TOC label), expose one prop per slot (titleAs, tocLabelAs) to keep semantics explicit.

Scope — affected components (audit)

Single-title components (one as prop each):

  • ProfileSection — currently h2
  • FAQ — currently h4
  • Slideshow (sections label) — currently h3
  • WorldClockBar — currently h2
  • TableOfContentsPanel — currently h2
  • TableOfContents — currently h3
  • KeyboardShortcutsHelp — currently h2
  • Watchlist — currently h2
  • OrderBook — currently h2 + h3
  • HorizontalScrollRow — currently h3
  • MarketTreemap — currently h2 + h3
  • ActivityHeatmap — currently h2
  • KeyConcept — currently h4
  • StatusBoard — currently h2
  • CodePlayground — currently h4
  • Comparison — currently h4
  • Quiz — currently h4
  • Exercise — currently h4
  • ShareSection — currently h3
  • CompletionDialog — currently h2
  • Checklist — currently h4
  • LearningObjectives — currently h4
  • CandlestickChart — currently h3
  • StepByStep — currently h3 + h4

Multi-title components (multiple slot props):

  • ContentIntroh2 title + h3 TOC label
  • TutorialCompleteh2 heading + h3 section labels

Out of scope (intentional structural mapping, do not change):

  • MdxContent, TutorialMdx, TutorialIntroContent — markdown-to-HTML heading map
  • FlowErrorBoundary — internal error UI, not a user title slot

Acceptance criteria

  • Each component listed above accepts an as (or per-slot *As) prop typed as "h1" | "h2" | "h3" | "h4" | "h5" | "h6".
  • Default value matches the current hardcoded tag — no breaking change.
  • Prop is documented in the component's TSDoc / Storybook story.
  • A11y story or visual test confirms the rendered tag updates.
  • Lighthouse heading-order audit passes when consumers pass the correct level.

Notes

  • Storybook stories should add an as knob so designers can preview the level shift.
  • Consider also exposing the prop on aria-level for components that render non-heading wrappers (out of scope here).
  • This is a non-breaking enhancement — safe for a minor release.

Happy to split this into per-component sub-issues if preferred.

Metadata

Metadata

Assignees

No one assigned

    Labels

    componentNew componentenhancementNew feature or requestneeds-triageMissing required metadata (type, etc.)p2-mediumMedium priority — quality of life

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions