Accessibility #
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,
tabindexandaria-activedescendantmove to the root element (which becomesrole="grid"), and.vlist-itemsbecomesrole="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:
withSelectionaccepts{ mode: 'single' | 'multiple' }. Without this feature, the list provides a baseline single-select via ArrowUp/Down, Home/End, PageUp/Down, and Space/Enter witharia-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:
- Items are recycled — the element pool constantly reuses DOM nodes, so moving
tabindex="0"between items would be fragile - Items may not exist — the focused item might be outside the rendered range (scrolled off-screen)
- One focus point — the root element stays focused (receives all keyboard events), and
aria-activedescendanttells 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:
.vlistroot (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: truein the list config orwithSelectionconfig. 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", uniqueid,aria-setsize,aria-posinset, andaria-selected aria-setsizereflects the total item count (not the row count)aria-posinsetuses 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", uniqueid,aria-setsize,aria-posinset,aria-selected, anddata-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-labelon.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 viaaria-selected.
Don't Interfere with Focus #
vlist manages focus on the root element. Avoid:
- Setting
tabindexon item content (breaksaria-activedescendantpattern) - 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-posinseton all rendered items- Unique element IDs with no collisions across instances
aria-activedescendantupdates on keyboard navigation and clickaria-busyduring 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:
- Tab into the list — focus ring should appear on the root element
- Press ↓ — screen reader should announce the first item with position ("item 1 of N")
- Press ↓↓↓ — each item announced with updated position
- Press Space — screen reader should announce selection ("1 item selected")
- Press Home / End — should jump to first / last item
- In grid mode, press ← / → — should navigate between cells in the same row
- In masonry mode, press ↓ — should stay in the same visual column
- Check with screen reader off — no visible live region, no broken layout
Recommended Screen Readers #
| 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 vialastAriaSetSize; 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 indexid— set once when an item is rendered; overwritten on pool recyclerole="option"— set once per element lifetime in the pool'sacquire()functionaria-activedescendant— onesetAttributecall on the root per keyboard/click event- Live region — one
textContentassignment 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.
Navigation Architecture #
Grid and masonry layouts register navigation hints via ctx.methods that the core and selection features resolve lazily:
- Grid registers
_getNavDeltawith{ 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
_getNavTotalwith the flat item count (not the virtual row count used by the size cache) - Masonry registers
_scrollItemIntoViewfor 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.
Related Documentation #
- 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