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 [tabindex="0"]
└── div.vlist-viewport
└── div.vlist-content
└── div.vlist-items [role="listbox"] [aria-label="..."]
├── 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"]
│ ...
└── ...
└── div.vlist-live [aria-live="polite"] [aria-atomic="true"] [role="status"]
(visually hidden — announces viewport range on scroll settle)
└── div.vlist-live-region [aria-live="polite"] [aria-atomic="true"]
(visually hidden — announces selection changes, added by withSelection)
Root Element #
The root .vlist element receives:
| Attribute | Value | Purpose |
|---|---|---|
tabindex |
"0" |
Makes the list focusable via Tab key |
aria-activedescendant |
Element ID | Points to the currently focused item (set on keyboard/click) |
aria-busy |
"true" |
Present only during async data loading |
Items Container (`.vlist-items`) #
The .vlist-items element receives:
| Attribute | Value | Purpose |
|---|---|---|
role |
"listbox" |
Identifies the widget as a list of selectable items |
aria-label |
User-provided | Describes the list's purpose to screen readers |
Item Elements #
Each rendered 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 |
Total item count | Enables "item X of Y" announcements |
aria-posinset |
1-based position | Enables "item X of Y" announcements |
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.
`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 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.
Active Descendant #
When keyboard focus moves, the root element's 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.
// After pressing ↓ twice:
// root.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. 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>
This gives screen reader users positional context during scrolling without interrupting navigation.
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.
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)
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