ElementInternals for Custom Grid Elements
Permalink to "ElementInternals for Custom Grid Elements"Custom elements like <data-grid>, <data-row> and <data-cell> start with no semantics — they are generic elements to assistive technology. Authors often fix this by setting role and aria-* attributes on the host in connectedCallback, which works but leaks implementation details into the light DOM and fights with author-provided attributes. ElementInternals offers a cleaner way: a custom element can declare its default role and ARIA properties internally, while page authors can still override them with attributes.
This guide belongs to web components & framework-agnostic grids.
Spec reference
Permalink to "Spec reference"HTMLElement.attachInternals()returns anElementInternalsobject (call once, in the constructor).- ARIA mixin on internals —
internals.role,internals.ariaSelected,internals.ariaExpanded,internals.ariaSort,internals.ariaRowIndex,internals.ariaLabel, and so on; these act as defaults that host attributes override. - Form-associated custom elements —
static formAssociated = true;internals.setFormValue(),setValidity()for validation messages. - Custom states —
internals.states.add('--selected')(CSS:state(selected)) for styling, distinct from ARIA. - Relevant criteria: SC 4.1.2, 1.3.1 (A).
When to use — and when not to
Permalink to "When to use — and when not to"Use internals for the default semantics of custom elements that represent ARIA roles directly — a <data-cell> that is a gridcell, a <data-row> that is a row. Use host attributes only when the page author provides them.
Do not rely on internals alone for relationships across shadow boundaries (see cross-root references). Do not use custom states as a substitute for ARIA states; they are for CSS.
The misapplication: setting this.setAttribute('role', 'gridcell') in connectedCallback on thousands of cells, which adds attribute noise, can be overwritten by frameworks, and may conflict with author attributes.
Annotated code example
Permalink to "Annotated code example"class DataCell extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
this.#internals.role = 'gridcell'; // default role (SC 4.1.2)
this.tabIndex = -1; // roving focus managed by the grid
}
set selected(v) {
this.#internals.ariaSelected = String(!!v); // state exposed to AT
v ? this.#internals.states.add('selected') : this.#internals.states.delete('selected'); // for CSS only
}
set colIndex(i) { this.#internals.ariaColIndex = String(i); } // for virtualized grids
}
customElements.define('data-cell', DataCell);
class DataRow extends HTMLElement {
#internals = this.attachInternals();
constructor() { super(); this.#internals.role = 'row'; }
set rowIndex(i) { this.#internals.ariaRowIndex = String(i); }
set expanded(v) { this.#internals.ariaExpanded = v == null ? null : String(v); } // treegrid rows
}
customElements.define('data-row', DataRow);
data-cell:state(selected) { background: var(--selected-bg); box-shadow: inset 3px 0 0 var(--brand-strong); }
data-cell:focus-visible { outline: 3px solid var(--focus); outline-offset: -3px; }
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Check | Expected |
|---|---|
Accessibility tree for <data-cell> |
Role gridcell, name from content |
| Selected cell | “selected” announced |
| Row with expanded internals | “expanded”/“collapsed” announced |
Author sets role="cell" on the host |
Author role wins |
| Browsers without internals ARIA support | Fallback: set attributes |
Integration context
Permalink to "Integration context"Cross-root relationships remain a limitation, covered in Shadow DOM and cross-root ARIA references. A Lit implementation using these ideas is in building an accessible data table in Lit. Grid roles and states are reviewed in composite widget roles & states.
Gotchas
Permalink to "Gotchas"Feature detection. Check 'role' in ElementInternals.prototype and fall back to attributes in older browsers.
Names from content. A gridcell’s name comes from its content — including shadow content. Ensure the text users should hear is rendered, not only drawn with CSS.
Form association. Editable cells that submit with a form can be form-associated; give them labels through the column header relationship or ariaLabel, and report validity with setValidity so errors are exposed.
Worked example
Permalink to "Worked example"A grid library rendered 20,000 <x-cell> custom elements, each calling setAttribute('role', 'gridcell') and setAttribute('aria-selected', …) on connect. A React wrapper re-rendered cells and stripped the attributes, so selection stopped being announced after the first update. Moving defaults and states to ElementInternals made them immune to framework attribute diffs, removed thousands of attributes from the DOM, and fixed selection announcements. Authors who needed role="cell" for a read-only variant could still set it on the host.
Testing it with a screen reader
Permalink to "Testing it with a screen reader"- Inspect custom elements in the accessibility tree; confirm roles and states.
- With NVDA, navigate the grid and confirm cells, rows and headers are announced as in a native grid.
- Toggle selection and expansion; confirm state changes are announced.
- Override a role with an attribute in a test page; confirm the override takes effect.
- Test in Firefox and Safari for support differences.
Common mistakes
Permalink to "Common mistakes"- Sprouting role attributes on every host.
- Custom states used instead of ARIA states.
- No fallback for browsers without internals ARIA.
- Relying on internals for cross-root references.
Adoption checklist for teams
Permalink to "Adoption checklist for teams"- Set default roles and states via internals in every custom element that represents an ARIA role.
- Keep author overrides working and document them.
- Provide a fallback path for older browsers.
- Separate ARIA states (for AT) from custom states (for CSS).
- Test the accessibility tree in all target browsers.
FAQ
Permalink to "FAQ"Does internals.role replace the role attribute?
It sets a default; an author’s role attribute overrides it.
Can I set aria-labelledby through internals?
Use element reflection properties where supported (ariaLabelledByElements); IDREF strings are scope-limited as usual.
Is ElementInternals widely supported?
Its ARIA features are supported in current major browsers; keep a fallback for older versions.
Should every cell be a custom element?
For large grids, many custom element instances can be costly. Consider rendering native elements inside one component’s shadow root instead.
Do custom states affect accessibility?
No. They are for CSS. Use ARIA states for assistive technology.
How do I test internals in unit tests?
jsdom has limited support; use real browsers (Playwright) to read the accessibility tree.
Can internals make a custom element focusable?
Focusability comes from tabindex or delegatesFocus on the shadow root, not from internals.
Should the grid container be a custom element with internals.role = 'grid'?
It can be. Then keep rows and cells as native elements inside its shadow root, or as light-DOM children with roles, so the structure stays in one tree.
Can ElementInternals expose aria-rowcount on a custom grid?
Yes — set internals.ariaRowCount on the grid element, and ariaRowIndex on rows, for virtualized grids.
What happens if both internals and an attribute set aria-selected?
The attribute wins. Avoid setting both from the component; reserve attributes for authors.
Related
Permalink to "Related"- Cross-root ARIA references — the relationship limitation
- Building a data table in Lit — putting it together
- aria-readonly and aria-disabled in grid cells — cell states