/ Docs

Accessibility #

PublishedUpdated May 13, 2026

WAI-ARIA listbox implementation, keyboard navigation, screen reader support, and focus management for vlist.

Overview #

vlist implements the WAI-ARIA Listbox pattern to provide a fully accessible virtual list experience. Because virtual lists only render a subset of items in the DOM at any time, standard accessibility patterns need careful adaptation — vlist handles this transparently.

What's Covered #

Feature Standard Description
ARIA roles WAI-ARIA 1.2 role="listbox" on .vlist-items, role="option" on items
Positional context aria-setsize / aria-posinset Screen readers announce "item 5 of 10,000"
Focus tracking aria-activedescendant Root element declares focused item by ID
Selection state aria-selected Each item reflects its selection state
Loading state aria-busy Announced during async data fetching
Live region aria-live="polite" Selection changes announced to screen readers
Keyboard navigation WAI-ARIA Practices Arrow keys, Home, End, Space, Enter
Focus visibility :focus-visible Keyboard-only focus ring (no mouse outline)
2D grid navigation WAI-ARIA Grid ArrowUp/Down by row, ArrowLeft/Right by cell, Home/End to first/last item
Lane-aware masonry navigation Custom ArrowUp/Down in same lane, ArrowLeft/Right to adjacent lane
Horizontal axis swap WAI-ARIA Grid Arrow keys swap for horizontal orientation

Works Across All Modes #

Every accessibility feature works in all vlist configurations:

  • ✅ List mode (vertical, horizontal)
  • ✅ Grid mode — with full 2D keyboard navigation
  • ✅ Masonry mode
  • ✅ Core (lightweight) mode
  • ✅ Groups / sticky headers
  • ✅ Reverse mode (chat UI)
  • ✅ Compressed mode (1M+ items)
  • ✅ Window scrolling
  • ✅ Async adapter

ARIA Roles & Attributes #

DOM Structure #

vlist produces the following accessible DOM hierarchy:

div.vlist
  └── div.vlist-viewport
       └── div.vlist-content
            └── div.vlist-items [role="listbox"] [tabindex="0"] [aria-label="..."]
                 │               [aria-activedescendant="vlist-0-item-3"]
                 ├── div.vlist-item [role="option"] [id="vlist-0-item-3"]
                 │     [aria-selected="false"]
                 │     [aria-setsize="10000"]
                 │     [aria-posinset="4"]
                 ├── div.vlist-item [role="option"] [id="vlist-0-item-4"]
                 │     [aria-selected="true"]
                 │     ...
                 │
                 │   With withGroups:
                 ├── div.vlist-group-header [role="presentation"] [id="vlist-0-item-5"]
                 │     (no aria-selected, aria-setsize, or aria-posinset)
                 ├── div.vlist-item [role="option"] [id="vlist-0-item-6"]
                 │     [aria-setsize="229"]       ← data total (excludes headers)
                 │     [aria-posinset="3"]         ← data-space position (excludes headers)
                 │     ...
                 └── ...
  └── div.vlist-live [aria-live="polite"] [aria-atomic="true"] [role="status"]
       (visually hidden — announces viewport range when enabled)
  └── div.vlist-live-region [aria-live="polite"] [aria-atomic="true"]
       (visually hidden — announces selection changes, added by withSelection)

Root Element #

The root .vlist element has no ARIA role or tabindex by default. In table mode (withTable), it is promoted to role="grid" with tabindex="0" and receives aria-activedescendant.

Attribute Value When
role "grid" Table mode only
tabindex "0" Table mode only
aria-activedescendant Element ID Table mode only
aria-busy "true" During async data loading

Items Container (`.vlist-items`) #

The .vlist-items element is the focusable ARIA role owner in list, grid, and masonry modes:

Attribute Value Purpose
role "listbox" Identifies the widget as a list of selectable items
tabindex "0" Makes the list focusable via Tab key
aria-activedescendant Element ID Points to the currently focused item (set on keyboard/click)
aria-label User-provided Describes the list's purpose to screen readers

Note: In table mode, tabindex and aria-activedescendant move to the root element (which becomes role="grid"), and .vlist-items becomes role="rowgroup".

Item Elements #

Each rendered data item receives:

Attribute Value Purpose
role "option" Identifies the element as a selectable option
id "vlist-{n}-item-{index}" Unique ID referenced by aria-activedescendant
aria-selected "true" / "false" Reflects selection state
aria-setsize Data item count Enables "item X of Y" announcements (excludes group headers)
aria-posinset 1-based data position Enables "item X of Y" announcements (excludes group headers from count)

Group Header Elements #

When using withGroups, group header pseudo-items receive a distinct set of attributes:

Attribute Value Purpose
role "presentation" Marks the header as a structural element, not a navigable option
class "vlist-group-header" Distinct class (instead of "vlist-item") for styling and identification
id "vlist-{n}-item-{index}" Unique ID (same scheme as data items)

Group headers do not receive aria-selected, aria-setsize, or aria-posinset — they are invisible to screen reader option navigation. Screen readers skip role="presentation" elements when navigating the listbox, so users move directly between data items.


Configuration #

`ariaLabel` #

Type: string Default: undefined

Sets aria-label on the .vlist-items element. Always provide this — screen readers need it to identify the list.

import { vlist } from 'vlist'

const list = vlist({
  container: '#app',
  ariaLabel: 'Contact list',
  item: { height: 48, template },
  items: contacts,
}).build()

Without ariaLabel, screen readers will announce the list generically (e.g., "listbox") without context. Good labels are short and descriptive:

✅ Good ❌ Bad
"Contact list" "List of all the contacts in the system"
"Search results" "Results"
"Chat messages" "Messages list container"
"Photo gallery" "Gallery" (too vague if multiple galleries exist)

`withSelection` #

Enable keyboard navigation and selection by adding the withSelection feature via the builder API. When added, the full keyboard interaction model activates (arrow keys, Home/End, Space/Enter), along with focus tracking (aria-activedescendant) and the live region for selection announcements.

import { vlist, withSelection } from 'vlist'

const list = vlist({
  container: '#app',
  ariaLabel: 'Task list',
  item: { height: 48, template },
  items: tasks,
})
  .use(withSelection({ mode: 'multiple' }))
  .build()

Note: withSelection accepts { mode: 'single' | 'multiple' }. Without this feature, the list provides a baseline single-select via ArrowUp/Down, Home/End, PageUp/Down, and Space/Enter with aria-activedescendant — but no multi-select and no selection live region.

`accessibility` #

Type: { announceVisibleRange?: boolean; rangeAnnouncementDebounce?: number } Default: { announceVisibleRange: false, rangeAnnouncementDebounce: 750 }

Controls screen reader announcements for visible range changes. See Live Regions for details.

Option Type Default Description
announceVisibleRange boolean false Announce "Showing items X to Y of Z" on scroll settle
rangeAnnouncementDebounce number 750 Debounce delay in ms for range announcements

`classPrefix` #

Type: string Default: 'vlist'

The class prefix also affects ARIA element IDs. Each instance generates IDs like {classPrefix}-{instanceId}-item-{index}. If you have multiple lists on the same page, you can use the default — the instance counter ensures uniqueness automatically.


Keyboard Navigation #

vlist supports three navigation models depending on which features are composed via the builder API.

Baseline (no `withSelection`) #

Built into the core — no features needed. Provides minimal single-select navigation:

Key Action
/ Move focus to the previous / next item
Home / End Move focus to the first / last item
Page Up / Page Down Move focus by a page of visible items
Space / Enter Toggle selection on the focused item
Tab Move focus into / out of the list (standard browser behavior)

Flat List (`withSelection`) #

Key Action
/ Move focus ±1
Home / End First / last item
Page Up / Page Down Move by visible items
Space / Enter Toggle selection

Grid (`withSelection` + `withGrid`) #

Following the WAI-ARIA Grid pattern:

Key Action
/ Move focus by ±columns (row navigation)
/ Move focus by ±1 (cell navigation)
Home / End First / last item overall
Page Up / Page Down Jump by visible rows (same column)
Space / Enter Toggle selection

In horizontal orientation, axes are swapped: Left/Right = ±columns (scroll axis), Up/Down = ±1 (cross axis).

Masonry (`withSelection` + `withMasonry`) #

Lane-aware navigation using pre-built per-lane index arrays:

Key Action
/ Previous / next item in the same lane (visual column)
/ Nearest item in the adjacent lane at similar y position
Home / End First / last item
Page Up / Page Down Jump by visible items in same lane
Space / Enter Toggle selection

In horizontal orientation, axes are swapped: Left/Right = same-lane navigation (scroll axis), Up/Down = adjacent-lane navigation (cross axis).

Multi-Select Shortcuts (`withSelection({ mode: 'multiple' })`) #

These additional shortcuts are available in multiple selection mode, on top of the navigation keys above:

Key Action
Shift + / Toggle selection of origin/destination item while moving focus
Shift + Space Select contiguous range from last-selected item to focused item
Shift + Click Range selection from last-selected item to clicked item
Ctrl + Shift + Home Select range from focused item to first item
Ctrl + Shift + End Select range from focused item to last item
Ctrl + A / Cmd + A Select all items (or deselect all if all are already selected)

How Focus Works #

vlist uses the aria-activedescendant pattern rather than roving tabindex. This is the correct approach for virtual lists because:

  1. Items are recycled — the element pool constantly reuses DOM nodes, so moving tabindex="0" between items would be fragile
  2. Items may not exist — the focused item might be outside the rendered range (scrolled off-screen)
  3. One focus point — the root element stays focused (receives all keyboard events), and aria-activedescendant tells the screen reader which item is logically active
User presses ↓
  → Root keeps DOM focus
  → root.setAttribute('aria-activedescendant', 'vlist-0-item-5')
  → Screen reader announces item 5's content
  → Item 5 scrolled into view if needed
  → CSS class .vlist-item--focused applied for visual indicator

Focus vs. Selection #

These are separate concepts:

Concept What it is Visual indicator ARIA attribute
Focus Keyboard cursor position (single index) .vlist-item--focused class aria-activedescendant on root
Selection Chosen items (set of IDs) .vlist-item--selected class aria-selected on each item

Arrow keys move focus without changing selection. Press Space or Enter to select the focused item.

`followFocus` Option #

.use(withSelection({ mode: 'single', followFocus: true }))

When enabled, arrow keys also select the focused item (WAI-ARIA "selection follows focus" pattern). Useful for single-select lists where navigation and selection should be unified (e.g., file browsers, settings panels).

`focusOnClick` Option #

// Baseline (no withSelection)
vlist({
  container: '#app',
  focusOnClick: true,
  item: { height: 48, template },
  items,
}).build()

// With withSelection
.use(withSelection({ mode: 'single', focusOnClick: true }))

By default, clicking an item updates the focused index but does not show the focus ring — this matches the web platform's :focus-visible convention where mouse users don't need a keyboard focus indicator.

When focusOnClick is true, the .vlist-item--focused class (and aria-activedescendant) is applied on click as well, making the focus ring visible. This is useful for file-manager, spreadsheet, or selection-heavy UIs where the focus indicator doubles as a "current item" marker.

Applies to both the baseline single-select and withSelection (regular clicks and Shift+clicks in multiple mode).


Screen Reader Support #

Positional Context #

Without aria-setsize and aria-posinset, a screen reader navigating a virtual list would have no idea how many items exist or where the current item falls in the sequence. vlist sets these on every rendered data item:

Screen reader output:
  "Alice Johnson, item 5 of 10,000"
  "Bob Smith, item 6 of 10,000"

When the total item count changes (e.g., after appendItems() or setItems()), aria-setsize is updated on all visible items during the next render cycle.

With groups: aria-setsize reports the data total (excluding group headers), and aria-posinset reports the data-space position (excluding headers from the count). For example, in a grouped list with 229 contacts and 26 letter headers (total layout items: 255), a screen reader announces "item 5 of 229" — not "item 5 of 255". This ensures the count reflects items the user can actually navigate to and interact with.

Active Descendant #

When keyboard focus moves, aria-activedescendant is updated to point to the focused item's id. The screen reader follows this reference and announces the item's content without the item needing actual DOM focus.

The attribute is set on the element that owns the composite ARIA role:

  • List, grid, masonry modes: .vlist-items (role="listbox")
  • Table mode: .vlist root (role="grid")
// After pressing ↓ twice in list mode:
// items.getAttribute('aria-activedescendant') === 'vlist-0-item-1'
// The element with id="vlist-0-item-1" has role="option"
// Screen reader reads its text content

Live Regions #

vlist uses two visually-hidden live regions for different purposes:

Core Live Region (`vlist-live`) #

Created by the core builder in all modes. When enabled, announces viewport range information when scrolling settles:

<div class="vlist-live" role="status"
     aria-live="polite" aria-atomic="true"
     style="position:absolute;width:1px;height:1px;...">
  Showing items 1 to 25 of 10000
</div>

Range announcements are opt-in via the accessibility config:

const list = vlist({
  container: '#app',
  ariaLabel: 'Search results',
  item: { height: 48, template },
  items: results,
  accessibility: {
    announceVisibleRange: true,        // default: false
    rangeAnnouncementDebounce: 750,    // default: 750ms
  },
}).build()

By default, range announcements are disabled because they can be noisy during keyboard navigation, touchpad scrolling, and repeated scroll pauses. Screen readers already receive positional context through aria-posinset and aria-setsize on each item. Enable range announcements for apps where "where am I in the dataset" is important (e.g., data dashboards, paginated reports).

The live region element is always created regardless of this setting — other features (withSelection, withSortable) use it for action-feedback announcements.

Selection Live Region (`vlist-live-region`) #

Created by withSelection. Announces selection count changes:

<div class="vlist-live-region"
     aria-live="polite" aria-atomic="true"
     style="position:absolute;width:1px;height:1px;...">
  3 items selected
</div>
Selection count Announcement
0 (cleared — no announcement)
1 "1 item selected"
3 "3 items selected"

Both live regions use aria-live="polite" so announcements don't interrupt the user's current reading flow.

Loading State #

When using an async adapter, vlist sets aria-busy="true" on the root element while data is loading. Screen readers may announce this as "busy" or defer reading until loading completes. The attribute is removed when load:end fires.

import { vlist } from 'vlist'

const list = vlist({
  container: '#app',
  ariaLabel: 'User directory',
  item: { height: 48, template },
  adapter: {
    read: async ({ offset, limit }) => {
      // aria-busy="true" set automatically on load:start
      const data = await fetchUsers(offset, limit);
      // aria-busy removed automatically on load:end
      return data;
    },
  },
}).build()

Unique Element IDs #

Each vlist instance generates a unique ID prefix using a module-level counter:

Instance 0:  vlist-0-item-0,  vlist-0-item-1,  vlist-0-item-2, ...
Instance 1:  vlist-1-item-0,  vlist-1-item-1,  vlist-1-item-2, ...

This ensures that multiple lists on the same page never produce duplicate IDs, which would break aria-activedescendant references.

The ID format is {classPrefix}-{instanceId}-item-{index}:

Segment Source Example
classPrefix Config (default: "vlist") vlist
instanceId Module-level counter 0, 1, 2, ...
index Item's position in the data 0, 1, 42, ...

Note: The full vlist and core (lightweight) vlist maintain separate instance counters. This is safe because you never use both for the same container.


CSS & Focus Styles #

Focus Ring #

The root element uses :focus-visible so the focus ring only appears during keyboard navigation, not on mouse click:

/* No outline on mouse focus */
.vlist:focus {
  outline: none;
}

/* Keyboard focus ring */
.vlist:focus-visible {
  outline: 2px solid var(--vlist-focus-ring);
  outline-offset: 2px;
}

Tip: By default, the focus ring only appears on keyboard navigation. To also show it on mouse click, set focusOnClick: true in the list config or withSelection config. See `focusOnClick` Option above.

Item States #

Focused and selected items receive CSS classes for visual differentiation:

Class When applied Default style
.vlist-item--focused Arrow key navigation moves to the item (combined with focused-visible below)
.vlist-item--focused-visible Available for keyboard-only focus ring on the item outline: 2px solid var(--vlist-focus-ring)
.vlist-item--selected Item is in the selection set background-color: var(--vlist-bg-selected)

Design Tokens #

Override these CSS custom properties to match your design system:

:root {
  --vlist-focus-ring: #3b82f6;           /* Focus ring color */
  --vlist-bg-selected: #eff6ff;          /* Selected item background */
  --vlist-bg-selected-hover: #dbeafe;    /* Selected item hover */
  --vlist-border-selected: #3b82f6;      /* Selected item accent */
}

Dark mode tokens are set automatically via prefers-color-scheme: dark or the .dark class. See Styling for the full token reference.


Grid Mode #

Grid mode follows the WAI-ARIA Grid pattern for 2D keyboard navigation:

  • Each grid item gets role="option", unique id, aria-setsize, aria-posinset, and aria-selected
  • aria-setsize reflects the total item count (not the row count)
  • aria-posinset uses the flat item index (not row/column)
  • ArrowUp/Down move by ±columns (row navigation)
  • ArrowLeft/Right move by ±1 (cell navigation)
  • Home/End go to first/last item overall
  • PageUp/Down jump by visible rows while staying in the same column
  • In horizontal orientation, Left/Right = ±columns (scroll axis), Up/Down = ±1 (cross axis)
import { vlist, withGrid, withSelection } from 'vlist'

const gallery = vlist({
  container: '#gallery',
  ariaLabel: 'Photo gallery',
  item: { height: 200, template: photoTemplate },
  items: photos,
})
  .use(withGrid({ columns: 4, gap: 8 }))
  .use(withSelection({ mode: 'single' }))
  .build()

// Screen reader: "Beach sunset, item 13 of 200"
// ArrowDown from item 5 → item 9 (same column, next row)
// ArrowRight from item 5 → item 6 (next cell)
// Home from item 6 → item 0 (first item overall)

Masonry Mode #

Masonry layouts use lane-aware navigation since items flow into the shortest column — there are no aligned rows. Navigation uses pre-built per-lane index arrays for O(1) same-lane and O(log k) adjacent-lane movement:

  • Each masonry item gets role="option", unique id, aria-setsize, aria-posinset, aria-selected, and data-lane
  • ArrowUp/Down move to the previous/next item in the same lane (visual column)
  • ArrowLeft/Right move to the nearest item in the adjacent lane at a similar vertical position
  • PageUp/Down jump by visible items in the same lane
  • In horizontal orientation, Left/Right = same-lane (scroll axis), Up/Down = adjacent-lane (cross axis)
import { vlist, withMasonry, withSelection } from 'vlist'

const gallery = vlist({
  container: '#gallery',
  ariaLabel: 'Photo gallery',
  item: {
    height: (i, ctx) => Math.round(ctx.columnWidth * photos[i].aspectRatio),
    template: photoTemplate,
  },
  items: photos,
})
  .use(withMasonry({ columns: 4, gap: 8 }))
  .use(withSelection({ mode: 'single' }))
  .build()

// ArrowDown stays in the same visual column
// ArrowRight finds the nearest photo in the column to the right

Core (Lightweight) Mode #

The core entry point provides the same ARIA attributes as the full bundle:

  • role="listbox" on .vlist-items / role="option" on items
  • aria-label on .vlist-items, tabindex="0" on root
  • aria-setsize, aria-posinset
  • ✅ Unique element IDs
  • aria-selected (always "false" — core has no selection)

Core provides a baseline single-select with ArrowUp/Down, Home/End, PageUp/Down, Space/Enter — no withSelection needed.

Core includes:

  • aria-activedescendant — updated on keyboard navigation and click (set in the baseline a11y handler)
  • ✅ Core live region (vlist-live, role="status") — announces viewport range ("Showing items X to Y of Z") on scroll settle

Core does not include:

  • ❌ Multi-select keyboard navigation (no selection feature)
  • ❌ Selection live region (no selection changes to announce)
  • aria-busy (no async adapter)
import { vlist } from 'vlist'

const list = vlist({
  container: '#app',
  ariaLabel: 'Log entries',
  item: { height: 24, template: logTemplate },
  items: logs,
}).build()

// Items have aria-setsize and aria-posinset ✓
// Baseline single-select via keyboard ✓
// aria-activedescendant updated on focus ✓
// Core live region for range announcements ✓
// No multi-select or selection live region

Best Practices #

Always Set `ariaLabel` #

Every list should have a descriptive label. Without it, screen readers announce a generic "listbox" with no context.

// ✅ Good
vlist({
  ariaLabel: 'Search results for "typescript"',
  ...
}).build()

// ❌ Missing label
vlist({
  // Screen reader: "listbox" — user has no idea what this list contains
  ...
}).build()

Use Semantic Templates #

Your template function generates the content that screen readers will read. Use meaningful text, not just visual decoration:

// ✅ Good — screen reader gets useful content
const template = (item) => {
  const el = document.createElement('div');
  el.textContent = `${item.name} — ${item.email}`;
  return el;
};

// ❌ Bad — screen reader gets nothing useful
const template = (item) => {
  return `<div class="avatar" style="background-image: url(${item.photo})"></div>`;
};

If your template is primarily visual, add a visually-hidden text label:

const template = (item) => {
  return `
    <img src="${item.photo}" alt="" />
    <span class="sr-only">${item.name}</span>
  `;
};

Handle the `state` Parameter #

The template function receives an ItemState with selected and focused booleans. Use these for visual feedback:

const template = (item: Item, index: number, state: ItemState) => {
  const el = document.createElement('div');
  el.textContent = item.name;
  if (state.selected) {
    el.innerHTML += ' <span aria-hidden="true">✓</span>';
  }
  return el;
};

The aria-hidden="true" on the checkmark prevents the screen reader from reading "check mark" — the selection state is already conveyed via aria-selected.

Don't Interfere with Focus #

vlist manages focus on the root element. Avoid:

  • Setting tabindex on item content (breaks aria-activedescendant pattern)
  • Adding interactive elements (buttons, links) inside items without careful focus management
  • Calling element.focus() on individual items

If you need interactive content inside items, consider handling clicks via event delegation on the list's item:click event rather than embedding focusable elements.


Testing Accessibility #

Automated Testing #

vlist includes 40 dedicated accessibility tests in test/accessibility.test.ts covering:

  • aria-setsize / aria-posinset on all rendered items
  • Unique element IDs with no collisions across instances
  • aria-activedescendant updates on keyboard navigation and click
  • aria-busy during async loading
  • Live region creation, announcements, and cleanup
  • Baseline ARIA attributes (roles, tabindex, aria-label, aria-selected)
  • Core mode, grid mode, and masonry mode coverage
  • 2D grid navigation and lane-aware masonry navigation

Manual Testing #

For manual verification:

  1. Tab into the list — focus ring should appear on the root element
  2. Press ↓ — screen reader should announce the first item with position ("item 1 of N")
  3. Press ↓↓↓ — each item announced with updated position
  4. Press Space — screen reader should announce selection ("1 item selected")
  5. Press Home / End — should jump to first / last item
  6. In grid mode, press ← / → — should navigate between cells in the same row
  7. In masonry mode, press ↓ — should stay in the same visual column
  8. Check with screen reader off — no visible live region, no broken layout
Platform Screen Reader Notes
macOS VoiceOver Built-in, activate with ⌘F5
Windows NVDA Free, widely used
Windows JAWS Commercial, industry standard
Linux Orca GNOME's built-in reader

Implementation Details #

Performance Considerations #

The accessibility attributes are designed for minimal performance impact:

  • aria-setsize — tracked via lastAriaSetSize; existing items only updated when total changes (rare, on data mutation — not every scroll frame)
  • aria-posinset — set once when an item is rendered; never changes for a given index
  • id — set once when an item is rendered; overwritten on pool recycle
  • role="option" — set once per element lifetime in the pool's acquire() function
  • aria-activedescendant — one setAttribute call on the root per keyboard/click event
  • Live region — one textContent assignment per selection change

The total bundle size cost for all accessibility features is +1.4 KB minified (+0.4 KB gzipped).

Element Pooling #

The element pool sets role="option" once when creating a new element. This attribute persists through recycles since the pool's release() function only clears styling and data attributes. When a recycled element is rendered for a new item, id, aria-setsize, aria-posinset, and aria-selected are all overwritten with the correct values.

When withGroups is active, group header elements receive role="presentation" and class vlist-group-header instead — the renderer detects header pseudo-items and applies the appropriate role and class at render time.

Compression Mode #

In compressed mode (1M+ items), all ARIA attributes work identically. The aria-setsize reflects the true total item count (e.g., 1,000,000), and aria-posinset reflects the true position — even though the scroll height is compressed to fit within browser limits.

Grid and masonry layouts register navigation hints via ctx.methods that the core and selection features resolve lazily:

  • Grid registers _getNavDelta with { ud: columns, lr: 1, cols: columns } — the core/selection keyboard handlers use these deltas for arrow key movement
  • Masonry registers _navigate — a custom function that uses per-lane index arrays (laneItems, itemLanePos, laneYCenters) for O(1) same-lane and O(log k) adjacent-lane navigation
  • Both register _getNavTotal with the flat item count (not the virtual row count used by the size cache)
  • Masonry registers _scrollItemIntoView for placement-based focus scrolling (the core size cache has no meaningful per-item offsets in masonry mode)

ARIA Data-Space Resolution #

When withGroups is active, the groups feature registers two methods on ctx.methods that renderers resolve lazily via createAriaResolvers:

  • _getTotal — returns the data item count (excluding group headers)
  • _layoutToDataIndex — maps a layout index to its data-space index (skipping header positions)

Each renderer resolves these once on first use and caches the references. Without groups, renderers fall back to the layout total and layoutIndex + 1 — zero overhead for non-grouped lists.


  • Selection — Selection state management and keyboard interaction internals
  • Grid — 2D grid layout with WAI-ARIA Grid keyboard navigation
  • Masonry — Lane-aware masonry layout with keyboard navigation
  • Rendering — Element pool, DOM structure, and renderer details
  • Styling — CSS tokens, variants, focus ring, and dark mode
  • Getting Started — Full configuration and API reference