aria-levelaria-level defines the hierarchical level of an element within its structure. Its most common real-world use is alongside role="heading", giving a non-native element a heading level that a real <h1>–<h6> can't provide: one that can be set dynamically, useful for components reused at different depths in a page, such as a card title that might need to act as an h2 on one page and an h3 on another.
role="heading" with aria-level="2"This shows up in the wild most often when content arrives without structure and structure has to be faked back in: a CMS field, a syndicated feed, or an API response that hands the page a title string with no indication of where it sits in the outline. A card component built to render that title might get reused in a sidebar (where it should behave like an h3) and on a landing page (where it should behave like an h2), so the level can't be hardcoded into the component's markup, it has to be set dynamically by whatever context drops the component in, and role="heading" with aria-level is what lets the level travel with that decision instead of being baked into a fixed element type.
Expected result: should be announced as a level 2 heading, and should appear in a screen reader's headings list alongside real headings.
<div role="heading" aria-level="2">
This behaves like a heading, level 2
</div>
This is the CMS/feed scenario made concrete. Imagine a "related content" card component: it renders a title from data with no structural information attached, just a string. The exact same component gets dropped into two different contexts on this page, a sidebar and a landing-page section, and each context needs the card's title to sit at a different depth in the outline. A native <h2>-<h6> element is fixed the moment it's written, so it can't do this, the component has no way to know its level until the page tells it. role="heading" with a dynamically-set aria-level is what lets the same markup serve both contexts correctly.
Context A: card sits inside a sidebar, one level under this section's own h3
A short related-content teaser, rendered from a CMS field with no heading markup of its own.
Context B: the same card component, now placed directly under this page's h1
Identical markup and identical component, only the level passed in has changed.
Expected result: the first card's title should be announced as a level 4 heading, and the second, identical, component should be announced as a level 2 heading. Confirm both appear correctly in a screen reader's headings list at their respective, different levels, despite being the same component with the same markup shape.
<!-- the component itself never changes -->
<div class="fake-card">
<div role="heading" aria-level="4">Understanding aria-level</div>
<p>A short related-content teaser...</p>
</div>
<!-- only the level passed in by the parent context changes -->
<div class="fake-card">
<div role="heading" aria-level="2">Understanding aria-level</div>
<p>Identical markup and identical component...</p>
</div>
aria-level in a treegridAway from headings, aria-level also applies to row in a treegrid. Treegrid rows are conventionally flat siblings in the DOM, so that arrow-key navigation moves predictably in two dimensions, which means depth can't be inferred from DOM ancestry the way it can in a nested tree. aria-level is what communicates the hierarchy instead. The example below is a small file browser: "Reports" and "Archive" are folders at levels 1 and 2, their contents sit one level deeper, and none of that nesting exists in the DOM, only in the aria-level value on each row.
Why not a native table? A native <table> has no concept of one row containing another, rows are flat and peer-level by definition. That rules out three things a table alone can't do: hierarchy (no attribute says "this row is nested inside that row", which is what aria-level provides), collapse state (no attribute says "this row's children are currently hidden", which is what aria-expanded provides), and 2D arrow-key navigation between cells, which treegrid implies and a plain table doesn't.
That said, treegrid is a narrow tool, not a fancier table. If the data is flat, use a native table. If it's hierarchical but doesn't need cell-by-cell keyboard navigation, a nested list or role="tree" is usually the better, simpler fit. Treegrid earns its place only when hierarchy, multiple columns, and per-cell interaction are all genuinely needed at once, which in practice is a fairly small set of cases (file browsers, spreadsheet-like tools).
Why not native headings instead? Headings describe the content that follows them, that's their whole job, a heading with nothing underneath it is meaningless, and a real heading should never sit on a page with no content beneath it. There's no such content here: "Reports" and "Archive" aren't titles introducing a following passage, they're row labels sitting next to data cells in the same row. That's a fundamentally different shape from what headings model, so this isn't a case of headings being technically unable to nest (aria-level handles that fine on role="heading", see Examples 1 and 2 above), it's that headings are the wrong semantic for row/cell data in the first place.
No cap at 6 in the spec, though support varies. Native headings only go to h6, that's an HTML limitation, not an ARIA one. aria-level itself is just <integer> with no spec-defined upper bound. In practice, though, support for values above 6 is inconsistent, and that's documented for headings specifically: Windows' UI Automation accessibility API caps heading levels at 9, and Chromium falls back to 6 for anything higher. Whether that same constraint applies to aria-level on treegrid rows is untested, worth verifying directly rather than assuming a deep hierarchy will be honoured. See "Is there really no limit?" in the companion article for the fuller picture.
Expected result: arrowing through the rows, level should be announced for each row (wording and position in the announcement varies by screen reader). Collapsing "Archive" via its toggle should hide its level-3 children from the accessibility tree entirely, and re-expanding "Reports" should not leave "Archive" incorrectly pre-expanded. Confirm aria-setsize/aria-posinset are announced sensibly (e.g. "2 of 2") given not all rows are present at once.
A note on the setsize/posinset numbering: reading straight down the markup, the aria-posinset values look out of order, 1, 1, 2, 1, 2, 2 rather than counting up cleanly. That's because aria-setsize/aria-posinset are scoped to a sibling group, same parent, same level, not to the treegrid as a whole. Each group restarts its own count. The R labels on the left show the other coordinate system running alongside it, absolute position in the grid, which is what aria-rowcount is counting:
R1: aria-rowcount: 7
R2: Reports (Level 1, 1 of 2)
R3: Q3 report.docx (Level 2, 1 of 2)
R4: Archive (Level 2, 2 of 2)
R5: Q1 report.docx (Level 3, 1 of 2)
R6: Q2 report.docx (Level 3, 2 of 2)
R7: budget.xlsx (Level 1, 2 of 2)
Each row is telling AT "I'm the Nth of M items at this level, under this parent," not "I'm the Nth row overall." Absolute row position is a separate attribute, aria-rowindex, which isn't set anywhere in this example since every row is genuinely present in the DOM (just sometimes CSS-hidden), so a browser can already calculate it itself, but the R1–R7 labels above show what those values would be if we had.
Why aria-rowcount is 7, not 6: the spec counts the header row as part of the total. R1 in the diagram above is the header row (Name / Type / Modified), then R2 through R7 are the six data rows. So aria-rowcount="7" is 1 header row + 6 data rows, not a count of data rows alone.
<div role="treegrid" aria-label="Project files">
<div role="row" aria-level="1" aria-expanded="true" aria-setsize="2" aria-posinset="1" data-id="reports">
<span role="gridcell">📁 Reports</span>
<span role="gridcell">Folder</span>
</div>
<div role="row" aria-level="2" data-parent="reports" aria-setsize="2" aria-posinset="1">
<span role="gridcell">📄 Q3 report.docx</span>
<span role="gridcell">Word doc</span>
</div>
<!-- a nested folder repeats the pattern one level deeper -->
<div role="row" aria-level="2" aria-expanded="true" data-parent="reports" data-id="archive">
<span role="gridcell">📁 Archive</span>
</div>
<div role="row" aria-level="3" data-parent="archive">
<span role="gridcell">📄 Q1 report.docx</span>
</div>
</div>
<!-- aria-level is on the row, never the gridcell -->
Note: aria-level only has an effect within a small set of roles that support it, chiefly heading, but also row, listitem, and treeitem in some structural contexts. Adding it to an element with no supporting role does nothing.
Note for testing: confirm with your standard browser and screen reader combinations that Examples 1 and 2 appear correctly in the headings list/navigation feature, at the expected levels, alongside real native headings.