A filterable, multi-select grid of checkbox rows — the inline (non-popover) counterpart to MultiSelect and Combobox. Map your data into options once; the list owns filtering and selection. Each row is an APG grid role="row" (aria-selected) whose cells hold a real Checkbox and a Choice-laid-out title + description — the pattern that lets a selectable row carry a real control and its own interactive content. The default row is fully click-to-toggle with a title and an optional de-emphasized sub-line; or, as a stopgap, break out with your own row content (see Custom rows). It renders every row by default; for long lists, swap Viewport → VirtualViewport. The example below has a disabled row (staging).
2 selected
Composition
SelectableList is a compound. Root owns selection + filter state and the derived filtered options; the parts below it read from that shared state.
Filterdrives a case-insensitive substring match over each option's plain text (labelText, or a stringlabel). Omit it for a non-filterable list.SelectAllreflects and toggles the selection of the currently filtered options (checked / indeterminate / unchecked), reusingselectAllChecked.Viewportrenders the rows as arole="grid"ofrole="row"rows (each withrole="gridcell"cells for the checkbox and the content). It's styled like theMultiSelectpopover: a bordered, roundedbg-popovercontainer whose rows highlight on hover / selection as an inset, rounded pill (the selected row picks up the accent tint, alongside its checked checkbox). Give it anaria-label(oraria-labelledby) so the grid has an accessible name, and bound its height (max-h-*/h-*, ormin-h-0 flex-1inside a flex parent). It renders every row (non-virtualized).VirtualViewportis the drop-in windowed variant: same rows and props, only the visible slice mounted. See Virtualization.Emptyrenders its children in place of the viewport when no options match the filter. It is an always-mounted politerole="status"live region (visually hidden while options match), so the empty state is announced, not just shown.
Empty and loading states. With zero
optionsand noEmptycomposed, the component renders nothing: the viewport unmounts entirely. ComposeSelectableList.Emptyso the list always has a message; its copy serves both "no data yet" (e.g. still loading) and "no filter matches".
Mark an option disabled to render its row dimmed and exclude it from toggling and "select all" (the legacy row below, and one in the list at the top of this page):
Custom rows
The default row (a title over a description) is the design-system row; prefer it. When a row must carry content the default shape can't express (a status badge, extra metadata, even interactive controls; the grid pattern permits a row to hold its own buttons/links), pass a render-prop child to a viewport and compose a SelectableList.Item.
Treat custom children as a stopgap, not a second design system. Item keeps owning the row (the role="row" chrome, click-to-toggle, aria-selected, and the checkbox gridcell) and renders your children into the content gridcell, a flex column beside the checkbox laid out by Choice. Everything inside that column is yours: hand-rolled, one-off layout for this row shape, not a mantle-styled surface.
Two rules keep the row's accessibility wiring intact:
- Always render
SelectableList.ItemTitle. It is the row'sChoice.Label: the real<label>that gives the checkbox its accessible name and makes the title click-to-toggle. - Never nest a
Choice.Rootinside anItem. The row already renders one; a nested root silently re-wires the title and description away from the row's checkbox.
The whole row is click-to-toggle, but it is not a <label>; it's a grid role="row". A bare click anywhere toggles selection, while clicks on the checkbox, the title label, or any nested control are left to those controls (no double-toggle). So a row can hold interactive content (a link, an overflow menu), each in the content cell as an ordinary sibling.
Why
optionsis still data.Rootneeds the full item set up front to support "select all" over the filtered set and to window rows underVirtualViewport(off-screen rows unmount); neither can be derived from rendered children, since unmounted rows can't report themselves. Sooptionsis the lightweight data layer (avalue, the filter text, anddisabled); a viewport's render-prop is the rendering layer. For rich rows, look your own data up byoption.value.
Virtualization
SelectableList.Viewport renders every filtered row. For a large option set (hundreds+), swap in SelectableList.VirtualViewport: it windows the rows via @tanstack/react-virtual, mounting only what's visible. Same props, same render-prop; only the tag changes. (@tanstack/react-virtual ships with every list entrypoint; it is small, a few kB gzipped, and does no windowing work until a VirtualViewport renders.) The example below has 60 options; scroll to see rows mount and unmount.
On the server, VirtualViewport renders the first slice of rows, sized by estimateItemHeight and overscan. The rest mount after hydration measures the viewport. Viewport renders every row in the server HTML. If the whole list must be in that HTML (for find-in-page, or for a reader without JavaScript), pick Viewport.
Windowing trade-off. Keyboard grid navigation still works across the full list: arrows move the active row and scroll it into view (
aria-activedescendant, plusaria-rowcounton the grid andaria-rowindexon each windowed row, keep screen-reader counts true). What windowing does cost: browser find-in-page (Ctrl/⌘-F) only searches mounted rows. The right trade for a large enough list; preferViewportuntil a list is actually long.
Controlled
Pass value + onValueChange to own the selection state (e.g. to show a count and a clear button).
Uncontrolled
Pass defaultValue instead and let the list manage selection internally.
Controlled query and custom filter
Like selection, the filter query is uncontrolled by default (seed it with defaultQuery) or controlled: pass query + onQueryChange to own it (e.g. to sync the filter with URL search params so a filtered view is shareable, or to clear it from outside the list). Pass filter to replace the default match (a trimmed, case-insensitive substring test over each option's plain text; see the exported optionLabelText helper) with your own predicate: it's called per option with the raw query; return true to keep the option. The example below controls the query and filters over the masked key description as well as the label text: the initial query ng-3f matches two keys by description alone (the default filter would match none).
query: “ng-3f”
Polymorphism
SelectableList.ItemDescription accepts asChild, rendering your single child in place of its default <p> and forwarding class names, data-* attributes, and ref onto it. It forwards to Choice.Description, so reach for it when the row's sub-line needs to be a different element than a paragraph.
No other part accepts asChild, and that is deliberate: SelectableList owns a role="grid" structure whose semantics depend on the elements it renders. Root, Filter, SelectAll, Viewport, and VirtualViewport own the grid semantics, filtering, select-all wiring, and virtualization measurement. Item renders the role="row" and its gridcell children around a real Checkbox, so swapping its element would break the row's aria-selected contract; compose your own layout through its children instead, which is unrestricted. ItemTitle forwards to Choice.Label, a real <label> wired to the checkbox by htmlFor, which is why it takes no asChild even though ItemDescription does.
Unlike List, a selectable row is not a link: use List when the row's job is to navigate.
Accessibility
The viewport is an APG grid: a role="grid" of role="row" rows, each with role="gridcell" cells holding a real <input type="checkbox"> and the title + description. This is the pattern the WAI-ARIA grid prescribes for selectable rows that carry their own interactive controls (a plain listbox forbids a real checkbox or nested controls inside an option). Set aria-label (or aria-labelledby) on the viewport so the grid has an accessible name. Each row's aria-selected conveys its selection alongside the checkbox's checked state; the title is a Choice.Label, so it names the checkbox, and the description is wired via aria-describedby. Disabled rows carry aria-disabled. SelectableList.Empty is an always-mounted polite role="status" live region, so an empty result is announced as the filter narrows rather than the grid silently vanishing.
Keyboard: the grid is a single tab stop; the checkboxes carry tabIndex={-1} in both viewports, so there is no per-checkbox Tab stop. Tab into it and Arrow↑/Arrow↓ / Home / End move an active row (tracked with aria-activedescendant, so focus never rides a row and survives virtualization), skipping disabled rows; Space / Enter toggles the active row. Keys pressed while a nested control inside a row (a link, an overflow-menu button, reached with Tab) has focus operate that control; the grid does not intercept them. The active row takes the hover tint (bg-active-menu-item, or bg-active-selected-menu-item when it's also selected), matching the MultiSelect popover. This works identically under VirtualViewport (arrow keys move across the full option set, scrolling off-screen rows into view) because focus stays on the collection, not on a row that could unmount. The keyboard model deliberately deviates from the full APG grid pattern: there is no Arrow←/Arrow→ per-cell navigation; the row is the unit of interaction.
Helpers
These pure functions back the component and are exported for custom filtering or select-all logic:
filterSelectableOptions(options, query): case-insensitive substring filter over each option's plain text (labelText, or a stringlabel).optionLabelText(option): that plain text for one option; reuse it in a customfilterpredicate to keep label matching consistent while adding your own criteria.toggleSelectionValue(values, value): add/remove a value, returning a new array.summarizeSelection(options, selected):{ allSelected, someSelected }(ignoring disabled options), ready forselectAllChecked.
API Reference
SelectableList.Root
Accepts all <div> props, plus:
SelectableListOption
SelectableList.Filter
The optional filter/search box: the mantle Input with a leading magnifying-glass icon, wired to the list's filter query. Accepts all Input props except value, defaultValue, onChange, type, and children, plus:
SelectableList.SelectAll
The optional tri-state "select all" header over the currently filtered options. Accepts all <label> props except htmlFor (it is wired to its own checkbox), plus:
children render inside a <span data-slot="selectable-list-select-all-label">. The checkbox is a permanent element sibling, so without the span a bare text label is never a lone child: a browser translation engine reparents that text node, and the removal React runs when a count-aware label swaps to an element throws. The span is display: contents, so the checkbox and the label stay flex items of the header and keep its gap. Browser translation covers the mechanism and the rules your own components need.
Its checkbox is automatically disabled when no filtered option is selectable.
SelectableList.Viewport
The non-virtualized grid. Accepts all <div> props (except children, which is the render-prop below), plus:
SelectableList.VirtualViewport
The windowed grid — same surface as Viewport, plus the virtualizer knobs:
SelectableList.Item
A selectable grid row (role="row") for custom viewport rendering. Selection and disabled state are read from the list by value. The row has no disabled prop; disabled state comes exclusively from the matching option's disabled (options[].disabled). Accepts all <div> props except id (reserved for the row's checkbox, which aria-activedescendant targets), plus:
SelectableList.ItemTitle / SelectableList.ItemDescription
The title and de-emphasized sub-line inside a SelectableList.Item. ItemTitle renders a Choice.Label (the real <label> wired to the row's checkbox); ItemDescription renders a Choice.Description (wired via aria-describedby).
SelectableList.Empty
Shown in place of the viewport when no options match the filter. Accepts all <div> props; children are the message (e.g. "No access keys found."). It is an always-mounted polite role="status" live region — visually hidden while options match — so the empty state is announced as the filter narrows.
The message renders inside a <span data-slot="selectable-list-empty-label">. The live region stays mounted, so React removes that span when options match again. The span exists so the node React removes is an element: a browser translation engine reparents text nodes, and a removal aimed at one throws. The span is display: contents, so it adds no box and your message stays a direct child of the centered region. Browser translation covers the mechanism.