List
A generic, accessible list that owns keyboard navigation, focus and virtualization, delegating row rendering to a `renderItem` callback — pair it with ListItem for ready-made rows.
Overview
List and ListItem are designed to be used together:
- List owns everything about how the list behaves: keyboard navigation (roving tabindex or active-descendant), focus management, selection semantics (
listbox/menuroles,aria-multiselectable) and — via a swappablelistslot — virtualization. It never renders a row itself; it calls yourrenderItem(item, state)for every item and hands you back navigation props (state.itemProps) plus anactivate()helper to spread onto whatever you render. - ListItem is the ready-made row: label/description text, an optional leading element, and either a
checkboxvariant (for multiselect) or amarkvariant (a check icon shown when selected). You spreadstate.itemPropsonto it so List's keyboard handling and focus tracking work.
You can use List without ListItem (render anything from renderItem), and ListItem is a plain presentational component you could reuse outside List — but the two are built to compose: List drives focus/selection, ListItem visualizes it.
Import
import { List } from '@unflow.io/ui/components/List';
import { ListItem } from '@unflow.io/ui/components/ListItem';List
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | Array<T & { onClick?: () => void }> | — | Required. Items to render. Each item may carry its own onClick, fired in addition to the list's onClick |
renderItem | (item: T, state: ListItemState) => ReactNode | — | Required. Renders a row. state gives you index, focused, itemProps (spread on the focusable element) and activate() (focuses the row and fires selection) |
onClick | (item: T, index: number) => void | — | Called when a row is activated — via click, or Enter/Space while it has keyboard focus |
multiselect | boolean | — | Sets aria-multiselectable on the container. List doesn't track selection itself — drive ListItem's selected from your own state |
virtualized | boolean | false | Switches to the active-descendant model (DOM focus stays on the container, the active row is tracked via aria-activedescendant). Pair with a list slot that only mounts visible rows |
rowHeight | number | 36 | Row height in px. Used for the container's max-height cap and passed to the list slot for virtualization math |
maxVisibleRows | number | 6 when virtualized, otherwise unset | Caps the container to N visible rows (maxHeight = maxVisibleRows * rowHeight); the list scrolls beyond that |
role | 'listbox' | 'menu' | 'listbox' | ARIA role of the container |
getItemKey | (item: T, index: number) => Key | index | React key for each row |
ref | Ref<ListHandle> | — | Exposes focusItem(index, align?) to programmatically move focus (and scroll, when virtualized) |
ListItem
Props
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Primary text |
description | string | — | Secondary text shown below the label |
startEl | ReactNode | — | Element rendered before the label/description (icon, avatar, etc.) |
selected | boolean | false | Marks the row as selected — sets aria-selected, checks the checkbox (variant="checkbox") or shows the check icon (variant="mark") |
focused | boolean | — | Visual focus indicator, exposed as data-focused — typically wired from ListItemState.focused |
variant | 'checkbox' | 'mark' | 'mark' | checkbox renders a Checkbox before the content (multiselect UI); mark shows a check icon after the content when selected |
ariaAttributes | Record<string, string | number | boolean> | — | Extra aria-* attributes spread onto the <li> |
index | number | — | Row index (informational, not used for behavior) |
ListItem also accepts standard <li> props (onClick, className, children, …) via BaseListItemProps<HTMLLIElement>. Passing children overrides the default startEl/label/description layout entirely.
Basic list
'use client';
import { useState } from 'react';
import { List } from '@unflow.io/ui/components/List';
import { ListItem } from '@unflow.io/ui/components/ListItem';
type Fruit = { id: number; label: string; description: string };
const fruits: Fruit[] = ['Apple', 'Banana', 'Cherry', 'Dragonfruit', 'Elderberry'].map(
(label, id) => ({ id, label, description: `A tasty ${label.toLowerCase()}` }),
);
export function Example() {
const [selectedId, setSelectedId] = useState<number | null>(null);
return (
<List
items={fruits}
getItemKey={(item) => item.id}
onClick={(item) => setSelectedId(item.id)}
renderItem={(item, { focused, itemProps, activate }) => (
<ListItem
{...itemProps}
onClick={activate}
label={item.label}
description={item.description}
selected={selectedId === item.id}
focused={focused}
/>
)}
/>
);
}Arrow keys move focus between rows; Enter/Space (or a click) calls activate(), which focuses the row and fires both renderItem's activate chain and List's onClick.
Menu of actions
Set role="menu" for an action list rather than a selectable list — there's no selected state, each item just runs its own onClick.
Last action: —
'use client';
import { useState } from 'react';
import { List } from '@unflow.io/ui/components/List';
import { ListItem } from '@unflow.io/ui/components/ListItem';
type Action = { id: string; label: string; onClick: () => void };
export function Example() {
const [lastAction, setLastAction] = useState('—');
const actions: Action[] = [
{ id: 'edit', label: 'Edit', onClick: () => setLastAction('Edit') },
{ id: 'duplicate', label: 'Duplicate', onClick: () => setLastAction('Duplicate') },
{ id: 'share', label: 'Share', onClick: () => setLastAction('Share') },
{ id: 'delete', label: 'Delete', onClick: () => setLastAction('Delete') },
];
return (
<List
role="menu"
items={actions}
getItemKey={(item) => item.id}
renderItem={(item, { focused, itemProps, activate }) => (
<ListItem {...itemProps} onClick={activate} label={item.label} focused={focused} />
)}
/>
);
}Each item's own onClick (set on the item object) fires when it's activated — List calls it before the list-level onClick.
Multiselect with checkboxes
Set multiselect on List and variant="checkbox" on ListItem, then track selected ids yourself and toggle them in onClick.
'use client';
import { useState } from 'react';
import { List } from '@unflow.io/ui/components/List';
import { ListItem } from '@unflow.io/ui/components/ListItem';
type Fruit = { id: number; label: string };
const fruits: Fruit[] = ['Apple', 'Banana', 'Cherry', 'Dragonfruit', 'Elderberry'].map(
(label, id) => ({ id, label }),
);
export function Example() {
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const toggle = (id: number) =>
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
return (
<List
multiselect
items={fruits}
getItemKey={(item) => item.id}
onClick={(item) => toggle(item.id)}
renderItem={(item, { focused, itemProps, activate }) => (
<ListItem
{...itemProps}
onClick={activate}
variant="checkbox"
label={item.label}
selected={selectedIds.has(item.id)}
focused={focused}
/>
)}
/>
);
}Virtualization
virtualized on its own only changes the focus model (active-descendant instead of roving tabindex). To actually window the rows you provide a list slot — a component that receives ListViewProps and is responsible for rendering only the visible rows (e.g. via react-window or @tanstack/react-virtual), and for reporting back to List's navigation core:
import { List, type ListViewProps } from '@unflow.io/ui/components/List';
function MyVirtualListView({
itemCount,
rowHeight,
maxVisibleRows,
renderItem,
containerProps,
registerScrollTo,
reportVisibleRange,
}: ListViewProps) {
// 1. Render only the rows currently in view using `renderItem(index)`.
// 2. Call `registerScrollTo` with a function that scrolls to a given index —
// List calls this internally when arrow keys move focus off-screen.
// 3. Call `reportVisibleRange({ start, stop })` whenever the visible window
// changes, so arrow-key navigation can re-enter at the right edge.
// ...
}
<List
virtualized
rowHeight={48}
maxVisibleRows={6}
items={manyItems}
slots={{ list: MyVirtualListView }}
renderItem={(item, { focused, itemProps, activate }) => (
<ListItem {...itemProps} onClick={activate} label={item.label} focused={focused} />
)}
/>;Without a list slot, virtualized still keeps every row mounted (like the default view) — only the focus model changes, so it's mainly useful once you've wired in a real windowing library.
Customization (slots & slotProps)
List
| Slot | Type | Description |
|---|---|---|
list | ComponentType<ListViewProps> | Renders the scroll container. Defaults to a plain <ul> with roving tabindex; provide one together with virtualized to plug in a virtualization library |
| Slot Prop | Props | Description |
|---|---|---|
list | BaseHTMLProps<HTMLUListElement> | Props merged onto the default (non-virtualized) <ul> container |
ListItem
| Slot | Type | Description |
|---|---|---|
checkbox | (checked: boolean) => ReactNode | Overrides the checkbox rendered when variant="checkbox" |
checkIcon | ReactNode | Overrides the check icon shown when variant="mark" and selected |
| Slot Prop | Props | Description |
|---|---|---|
startElRoot | BaseHTMLProps<HTMLDivElement> | Wrapper around startEl |
contentRoot | BaseHTMLProps<HTMLDivElement> | Wrapper around the label/description block |
label | BaseHTMLProps<HTMLParagraphElement> | The label <p> |
description | BaseHTMLProps<HTMLParagraphElement> | The description <p> |
checkIconRoot | BaseHTMLProps<HTMLDivElement> | Wrapper around the check icon (variant="mark") |
checkbox | Omit<ICheckbox, 'checked'> | Props for the default checkbox (variant="checkbox") — checked is controlled by selected |
Accessibility
- List drives keyboard interaction itself: ArrowUp/ArrowDown move focus between rows (wrapping at the ends), Enter/Space activate the focused row.
- Non-virtualized lists use roving tabindex — only the focused row has
tabIndex={0}, the rest are-1and DOM focus genuinely moves between rows. - Virtualized lists use the active-descendant pattern instead — DOM focus stays on the container (
tabIndex={0}), and the active row is exposed viaaria-activedescendant, since a genuinely focused row can be unmounted when scrolled out of view. role="listbox"+multiselectsetsaria-multiselectableon the container; eachListItemsetsaria-selectedfrom itsselectedprop, andaria-label/aria-descriptionderived fromlabel/description(or your own overrides).ListItem'sfocusedprop only drives adata-focusedattribute for styling — actual focus/selection state and semantics come fromList.
Scrollable
A scroll container with a consistently styled custom scrollbar. Supports vertical, horizontal, or both-axis scrolling with optional smooth scroll and persistent scrollbar gutter.
Backdrop
A full-screen overlay that traps focus, locks scroll, and closes on click-away or Esc — the primitive behind Alert and other modal surfaces.