skip to content

In a shared component library, a card component renders its own title, but pages nest that card at different depths so the correct heading rank varies. How would you design the component's markup contract, and what are the tradeoffs of each option?

level: principalimportance: should knowfreq 28%

answer

  1. rank belongs to the position, not the part
  2. the page knows what the component cannot
  3. visible failures beat invisible ones
  4. hardcoded rank means no outline at all
  5. enforce on rendered pages, not source

basics

~20 s

Make the heading rank an input the page supplies, since only the page knows the nesting depth. Deriving rank from a composition context automates it but hides breakage; hardcoding one rank everywhere flattens outlines; role="heading" with aria-level is the last resort.

solid answer

~60 s

The component cannot know its own depth, so the rank has to come from outside. My default contract is an explicit heading-level input with a sensible default and a documented allowed range — boring, greppable, and easy to audit. A composition-derived level, where nesting establishes the rank automatically, is more ergonomic and stays correct as pages are rearranged, but the level becomes invisible at the call site and breaks quietly when a component is moved, portalled, or rendered outside the expected nesting. Hardcoding a single rank is the option teams drift into; it produces pages where every title is an `h3` and the outline is flat. `role="heading"` with `aria-level` on a generic element is a genuine escape hatch — it can express levels past 6 — but you lose the native element and take on setting the value correctly, so I only reach for it where I cannot emit the right tag. Whichever I pick, I back it with automated checks and reviews that read the page's heading sequence.

go deeper

for a junior

Understand the core constraint: a reusable component does not know where it sits, so the correct heading level has to be supplied by the page that uses it.

for a middle

Explain why nothing in the platform derives rank from nesting, and describe how a level input renders the matching h1-h6 tag while CSS keeps the visual size fixed.

for a senior

Weigh the options on failure mode — an explicit level fails visibly and locally, a derived level fails silently when components move — and back whichever you choose with checks on rendered pages.

for a principal

Own the contract across teams: decide who may emit the h1, make a flat-outline component API structurally impossible, and be able to justify accepting some ergonomic cost in exchange for failures that are visible in review.

## Why this is a design problem, not a markup problem Heading rank is a property of *position in the document*. A component is defined independently of position. That mismatch is structural: no amount of cleverness inside the component recovers information it was never given, and nothing in the platform computes rank from nesting — heading level is read straight off the tag name. So the only question is how the page's knowledge reaches the component, and every option is a tradeoff between ergonomics, auditability and failure mode. ## Option 1: rank as an explicit input The component accepts something like a `headingLevel` value and renders the matching tag. ```html <!-- what the page ends up emitting for a card placed under an h2 --> <article class="card"> <h3 class="card__title">Refund window</h3> <p>Thirty days from delivery.</p> </article> ``` **Strengths.** The level is visible where the decision is made. Reviewers can see it in the diff. It is greppable, so an audit can find every call site. It survives moving the component, because moving it forces you to touch the call. **Weaknesses.** It is repetitive, and it can be wrong at every call site independently. Under composition — a card inside a panel inside a page section — humans miscount. And a default value is a trap: whichever default you pick will be silently wrong somewhere. This is still my starting point, because its failures are *visible* and its fixes are local. ## Option 2: rank derived from composition A container establishes a level, and anything rendered inside it renders one rank deeper, so nesting produces the outline automatically. This is essentially the intent of the HTML5 outline algorithm — the difference being that here you implement it yourself in application code rather than expecting the platform to do it, and it can therefore genuinely work. **Strengths.** Correct-by-construction for the common case. Pages can be rearranged without touching every title. It removes the class of bug where someone copies a call site into a deeper context. **Weaknesses.** The rank is invisible at the call site, so nothing in a diff shows you it changed. It breaks in exactly the cases that are hardest to notice: a component rendered outside the nesting it assumes, content moved between containers, or anything rendered into a different part of the tree than where it is written. Deep composition can also run past level 6, which native tags cannot express. And it creates a rule every contributor must learn before their markup is correct. **When it wins.** Large, deeply-composed products with many contributors, where per-call-site correctness has already failed empirically. Adopt it with a test that renders representative pages and asserts the heading sequence. ## Option 3: one hardcoded rank everywhere Every card title is, say, an `h3`. Nobody chose this; teams arrive at it. It is not a compromise, it is a decision to have no outline. Every page's heading list is a flat run of same-level entries, and rank-based navigation stops distinguishing anything. It also hides the problem from tooling: skipped-level checks pass, because nothing is skipped — the structure is simply absent. Treat a component whose title tag is fixed in its implementation as a defect to fix, not a convention to keep. ## Option 4: role="heading" with aria-level ```html <div role="heading" aria-level="7">Nested subsection title</div> ``` This is real ARIA and it is honestly the right tool in two narrow cases: markup produced by a constrained renderer that cannot emit arbitrary tags, and genuine nesting deeper than six levels. The costs are real too — you give up the native element, you own the numeric value, and the default level for `role="heading"` is 2, so an omitted `aria-level` silently mislabels the heading. As a general strategy it violates the native-first instinct for no benefit. Worth noting that nesting deeper than six levels is usually a content-structure smell before it is a markup problem. The honest first response is to question the depth. ## What holds it together regardless of choice Whichever contract you pick, it decays without enforcement: - **Automated checks in CI.** Rules like axe-core's `heading-order` catch skipped levels on rendered pages. They cannot catch a flat outline or an unhelpful `h1`, so they are a floor, not a guarantee. - **A heading-sequence review step.** Reading only the headings of a page, in order, and asking whether it works as a table of contents is a two-minute check that catches what tooling cannot. - **A documented rule for the `h1`.** Exactly one per page, naming the page. That is a page-template concern, not a component one, and it is worth owning centrally so no component can claim it. - **A component API that makes the wrong thing hard.** If the title tag is not configurable at all, the flat outline is guaranteed by construction and no review will save you. ## The framing to give in an interview "The rank belongs to the page, not the component, so the contract has to carry it. I default to an explicit level input because its failures are visible, consider a composition-derived level once scale makes per-call-site correctness unreliable, reject a hardcoded rank because it silently deletes the outline, and keep `role="heading"` for the cases where I cannot emit a native tag. Then I back the choice with rendered-page checks, because none of these survive contact with a large team unenforced."

  • If deriving levels from composition is correct-by-construction, why not always prefer it?
    Because its failure mode is silent. The level never appears at the call site, so a diff shows nothing when a change alters it, and rendering a component outside its expected nesting produces a wrong rank nobody sees. It also can exceed level 6 in deep trees. It earns its place at scale, backed by rendered-page assertions, not by default.
  • What does automated tooling actually catch here, and what will it never catch?
    Rule-based checks such as axe-core's `heading-order` catch skipped levels on a rendered page, and validators catch invalid nesting. Nothing automated can tell you an outline is flat but consistent, that the `h1` names the site rather than the page, or that heading text is uninformative — those need a human reading the heading sequence.
  • A team argues that no page in the product needs more than two heading levels. How do you respond?
    Check it against real pages rather than arguing in the abstract: extract the heading sequence from the most complex ones. If two levels genuinely describe them, the simple contract is fine. Usually the exercise finds three or four levels of visible structure that the flat markup was quietly discarding.
  • Where does the h1 fit into this contract?
    Outside it. Exactly one `h1` per page, naming that page, is a page-template responsibility — no reusable component should be able to emit one, or you get duplicate top-level headings the moment a page renders two instances. Making that structurally impossible in the component API is better than documenting it.

saying these in an interview costs you the question

  • Says the component can infer its level from the DOM at runtime
  • Hardcodes one heading tag in a shared component
  • Claims nesting inside section elements fixes the rank automatically
  • Reaches for role="heading" as the general solution
  • Treats a passing automated check as proof the outline is good

context