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 an ElementInternals object (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).
Where semantics come from on a custom element Layers: author attributes on the host take precedence; ElementInternals defaults apply when no attribute is set; shadow DOM content supplies names when the role allows; and custom states drive styling only. Where semantics come from on a custom elementAuthor attributesrole or aria-* on the host override defaultsElementInternalsdefaultsrole and states set by the componentShadow contenttext contributes to names where the role allowsCustom states:state() for styling, not for assistive technology
Defaults via internals keep authors in control.

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.

Host attributes versus internals defaults Comparison: setting role and aria attributes on the host from inside the component sprouts attributes and can clash with authors; ElementInternals defaults stay internal and yield to author attributes. Host attributes versus internals defaults✗ setAttribute on the hostAttributes appear in the light DOMFrameworks may overwrite themAuthor overrides conflictSprouting on every instance✓ ElementInternals defaultsNo attribute noiseNot touched by framework diffsAuthor attributes win cleanlySet once per instance internally

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
Internals ARIA support and fallbacks Matrix of browsers — Chromium, Firefox, Safari — against internals role and ARIA properties, element reflection and custom states. Internals ARIA support and fallbacksInternals role and ARIAElement reflectionCustom statesChromiumYesYesYesFirefoxYes (recent)PartialRecentSafariYes (recent)PartialRecent
Modern browsers support internals ARIA; keep a fallback for older ones.

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"
  1. Inspect custom elements in the accessibility tree; confirm roles and states.
  2. With NVDA, navigate the grid and confirm cells, rows and headers are announced as in a native grid.
  3. Toggle selection and expansion; confirm state changes are announced.
  4. Override a role with an attribute in a test page; confirm the override takes effect.
  5. 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"
  1. Set default roles and states via internals in every custom element that represents an ARIA role.
  2. Keep author overrides working and document them.
  3. Provide a fallback path for older browsers.
  4. Separate ARIA states (for AT) from custom states (for CSS).
  5. 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.

Permalink to "Related"

← Back to Web Components & Framework-Agnostic Grids