Skip to main content

Selectable List

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).

ng-000000***0000

ng-000001***0007

ng-000002***000e

ng-000003***000l

ng-000004***000s

ng-000005***000z

ng-000006***0016

ng-000007***001d

ng-000008***001k

ng-000009***001r

ng-00000a***001y

ng-00000b***0025

ng-00000c***002c

ng-00000d***002j

ng-00000e***002q

ng-00000f***002x

ng-00000g***0034

ng-00000h***003b

ng-00000i***003i

ng-00000j***003p

ng-00000k***003w

ng-00000l***0043

ng-00000m***004a

ng-00000n***004h

ng-00000o***004o

ng-00000p***004v

ng-00000q***0052

ng-00000r***0059

ng-00000s***005g

ng-00000t***005n

ng-00000u***005u

ng-00000v***0061

ng-00000w***0068

ng-00000x***006f

ng-00000y***006m

ng-00000z***006t

ng-000010***0070

ng-000011***0077

ng-000012***007e

ng-000013***007l

ng-000014***007s

ng-000015***007z

ng-000016***0086

ng-000017***008d

ng-000018***008k

ng-000019***008r

ng-00001a***008y

ng-00001b***0095

ng-00001c***009c

ng-00001d***009j

ng-00001e***009q

ng-00001f***009x

ng-00001g***00a4

ng-00001h***00ab

ng-00001i***00ai

ng-00001j***00ap

ng-00001k***00aw

ng-00001l***00b3

ng-00001m***00ba

ng-00001n***00bh

2 selected

import { SelectableList } from "@ngrok/mantle/selectable-list";import { useState } from "react"; const options = [	{ value: "onboarding", label: "Onboarding Key", description: "ng-3FbZthp***xcc3" },	{ value: "prod", label: "prod", description: "ng-3FaThZL***8xiA" },	{ value: "staging", label: "staging", description: "ng-7HkLmNp***1q2w", disabled: true },]; function Example() {	const [selected, setSelected] = useState<string[]>([]); 	return (		<SelectableList.Root options={options} value={selected} onValueChange={setSelected}>			<SelectableList.Filter placeholder="Filter access keys…" aria-label="Filter access keys" />			<SelectableList.SelectAll>Select all</SelectableList.SelectAll>			<SelectableList.Viewport aria-label="Access keys" className="max-h-80" />			<SelectableList.Empty>No access keys found.</SelectableList.Empty>		</SelectableList.Root>	);}

Composition

SelectableList is a compound. Root owns selection + filter state and the derived filtered options; the parts below it read from that shared state.

SelectableList.Root├── SelectableList.Filter├── SelectableList.SelectAll├── SelectableList.Viewport            (or .VirtualViewport)│   └── SelectableList.Item            (optional render-prop; auto-rendered by default)│       ├── SelectableList.ItemTitle│       └── SelectableList.ItemDescription└── SelectableList.Empty
  • Filter drives a case-insensitive substring match over each option's plain text (labelText, or a string label). Omit it for a non-filterable list.
  • SelectAll reflects and toggles the selection of the currently filtered options (checked / indeterminate / unchecked), reusing selectAllChecked.
  • Viewport renders the rows as a role="grid" of role="row" rows (each with role="gridcell" cells for the checkbox and the content). It's styled like the MultiSelect popover: a bordered, rounded bg-popover container 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 an aria-label (or aria-labelledby) so the grid has an accessible name, and bound its height (max-h-* / h-*, or min-h-0 flex-1 inside a flex parent). It renders every row (non-virtualized).
  • VirtualViewport is the drop-in windowed variant: same rows and props, only the visible slice mounted. See Virtualization.
  • Empty renders its children in place of the viewport when no options match the filter. It is an always-mounted polite role="status" live region (visually hidden while options match), so the empty state is announced, not just shown.

Empty and loading states. With zero options and no Empty composed, the component renders nothing: the viewport unmounts entirely. Compose SelectableList.Empty so 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):

ng-3FaThZL***8xiA

ng-7HkLmNp***1q2w

ng-1AbCdEf***0000

import { SelectableList } from "@ngrok/mantle/selectable-list";import { useState } from "react"; const options = [	{ value: "prod", label: "prod", description: "ng-3FaThZL***8xiA" },	{ value: "staging", label: "staging", description: "ng-7HkLmNp***1q2w" },	// A disabled row renders dimmed and can't be toggled or "select all"-ed.	{ value: "legacy", label: "legacy", description: "ng-1AbCdEf***0000", disabled: true },]; function Example() {	const [selected, setSelected] = useState<string[]>(["prod"]); 	return (		<SelectableList.Root options={options} value={selected} onValueChange={setSelected}>			<SelectableList.SelectAll>Select all</SelectableList.SelectAll>			<SelectableList.Viewport aria-label="Access keys" className="max-h-72" />		</SelectableList.Root>	);}

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.

SelectableList.Item                     role="row" — selection + disabled read from the list by `value`├── checkbox gridcell                   provided by Item — the row's real Checkbox└── content gridcell (flex column)      your children render here    ├── SelectableList.ItemTitle        the row's Choice.Label — names the checkbox; always render it    └── SelectableList.ItemDescription  a Choice.Description — associated via aria-describedby

Two rules keep the row's accessibility wiring intact:

  • Always render SelectableList.ItemTitle. It is the row's Choice.Label: the real <label> that gives the checkbox its accessible name and makes the title click-to-toggle.
  • Never nest a Choice.Root inside an Item. The row already renders one; a nested root silently re-wires the title and description away from the row's checkbox.
expiring soon

ng-3FbZthp***xcc3

active

ng-3FaThZL***8xiA

active

ng-7HkLmNp***1q2w

active

ng-9XyZ012***3r4t

expiring soon

ng-4QrStUv***5w6x

import { Badge } from "@ngrok/mantle/badge";import { SelectableList } from "@ngrok/mantle/selectable-list";import { useState } from "react"; const options = [	{ value: "onboarding", label: "Onboarding Key", description: "ng-3FbZthp***xcc3" },	{ value: "prod", label: "prod", description: "ng-3FaThZL***8xiA" },	{ value: "staging", label: "staging", description: "ng-7HkLmNp***1q2w" },]; const keyStatus: Record<string, "active" | "expiring soon"> = {	onboarding: "expiring soon",	prod: "active",	staging: "active",}; function Example() {	const [selected, setSelected] = useState<string[]>(["prod"]); 	return (		<SelectableList.Root options={options} value={selected} onValueChange={setSelected}>			<SelectableList.Viewport aria-label="Access keys" className="max-h-80">				{(option) => {					// Rich row data that isn't part of the option — look it up by `option.value`.					const status = keyStatus[option.value];					return (						<SelectableList.Item value={option.value}>							{/* Stopgap: inside Item you are hand-rolling the row's content cell.									Keep ItemTitle — it is the label that names the row's checkbox. */}							<div className="flex items-center gap-2">								<SelectableList.ItemTitle>{option.label}</SelectableList.ItemTitle>								<Badge appearance="muted" color={status === "active" ? "success" : "warning"}>									{status}								</Badge>							</div>							<SelectableList.ItemDescription translate="no" className="font-mono">								{option.description}							</SelectableList.ItemDescription>						</SelectableList.Item>					);				}}			</SelectableList.Viewport>		</SelectableList.Root>	);}

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 options is still data. Root needs the full item set up front to support "select all" over the filtered set and to window rows under VirtualViewport (off-screen rows unmount); neither can be derived from rendered children, since unmounted rows can't report themselves. So options is the lightweight data layer (a value, the filter text, and disabled); a viewport's render-prop is the rendering layer. For rich rows, look your own data up by option.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.

ng-000000***0000

ng-000001***0007

ng-000002***000e

ng-000003***000l

ng-000004***000s

ng-000005***000z

ng-000006***0016

ng-000007***001d

ng-000008***001k

ng-000009***001r

ng-00000a***001y

ng-00000b***0025

ng-00000c***002c

ng-00000d***002j

ng-00000e***002q

ng-00000f***002x

ng-00000g***0034

import { SelectableList } from "@ngrok/mantle/selectable-list";import { useState } from "react"; // `VirtualViewport` is a drop-in for `Viewport` — same props, same render-prop.function Example() {	const [selected, setSelected] = useState<string[]>([]); 	return (		<SelectableList.Root options={accessKeyOptions} value={selected} onValueChange={setSelected}>			<SelectableList.Filter placeholder="Filter access keys…" aria-label="Filter access keys" />			<SelectableList.SelectAll>Select all</SelectableList.SelectAll>			<SelectableList.VirtualViewport aria-label="Access keys" className="max-h-80" />			<SelectableList.Empty>No access keys found.</SelectableList.Empty>		</SelectableList.Root>	);}

Windowing trade-off. Keyboard grid navigation still works across the full list: arrows move the active row and scroll it into view (aria-activedescendant, plus aria-rowcount on the grid and aria-rowindex on 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; prefer Viewport until a list is actually long.

Controlled

Pass value + onValueChange to own the selection state (e.g. to show a count and a clear button).

ng-3FbZthp***xcc3

ng-3FaThZL***8xiA

ng-7HkLmNp***1q2w

ng-9XyZ012***3r4t

ng-4QrStUv***5w6x

1 selected
import { Button } from "@ngrok/mantle/button";import { SelectableList } from "@ngrok/mantle/selectable-list";import { useState } from "react"; const options = [	{ value: "onboarding", label: "Onboarding Key", description: "ng-3FbZthp***xcc3" },	{ value: "prod", label: "prod", description: "ng-3FaThZL***8xiA" },	{ value: "staging", label: "staging", description: "ng-7HkLmNp***1q2w" },]; function Example() {	const [selected, setSelected] = useState<string[]>(["onboarding"]); 	return (		<div className="space-y-3">			<SelectableList.Root options={options} value={selected} onValueChange={setSelected}>				<SelectableList.SelectAll>Select all</SelectableList.SelectAll>				<SelectableList.Viewport aria-label="Access keys" className="max-h-72" />			</SelectableList.Root>			<div className="flex items-center justify-between text-sm">				<span className="text-muted">{selected.length} selected</span>				<Button					type="button"					appearance="outlined"					intent="neutral"					onClick={() => setSelected([])}					disabled={selected.length === 0}				>					Clear				</Button>			</div>		</div>	);}

Uncontrolled

Pass defaultValue instead and let the list manage selection internally.

ng-3FbZthp***xcc3

ng-3FaThZL***8xiA

ng-7HkLmNp***1q2w

ng-9XyZ012***3r4t

ng-4QrStUv***5w6x

import { SelectableList } from "@ngrok/mantle/selectable-list"; const options = [	{ value: "onboarding", label: "Onboarding Key", description: "ng-3FbZthp***xcc3" },	{ value: "prod", label: "prod", description: "ng-3FaThZL***8xiA" },	{ value: "staging", label: "staging", description: "ng-7HkLmNp***1q2w" },]; <SelectableList.Root options={options} defaultValue={["prod"]}>	<SelectableList.Filter placeholder="Filter access keys…" aria-label="Filter access keys" />	<SelectableList.Viewport aria-label="Access keys" className="max-h-72" />	<SelectableList.Empty>No access keys found.</SelectableList.Empty></SelectableList.Root>;

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).

ng-3FbZthp***xcc3

ng-3FaThZL***8xiA

query: “ng-3f”

import { optionLabelText, SelectableList } from "@ngrok/mantle/selectable-list";import { useState } from "react"; const options = [	{ value: "onboarding", label: "Onboarding Key", description: "ng-3FbZthp***xcc3" },	{ value: "prod", label: "prod", description: "ng-3FaThZL***8xiA" },	{ value: "staging", label: "staging", description: "ng-7HkLmNp***1q2w" },	{ value: "ci", label: "CI deploy", description: "ng-9XyZ012***3r4t" },	{ value: "edge", label: "edge worker", description: "ng-4QrStUv***5w6x" },]; function Example() {	const [query, setQuery] = useState("ng-3f");	const [selected, setSelected] = useState(["prod"]); 	return (		<div className="space-y-3">			<SelectableList.Root				options={options}				value={selected}				onValueChange={setSelected}				query={query}				onQueryChange={setQuery}				filter={(option, query) => {					const needle = query.trim().toLowerCase();					if (needle === "") {						return true;					}					const description = typeof option.description === "string" ? option.description : "";					return (						optionLabelText(option).toLowerCase().includes(needle) ||						description.toLowerCase().includes(needle)					);				}}			>				<SelectableList.Filter					placeholder="Filter by name or key…"					aria-label="Filter access keys"				/>				<SelectableList.Viewport aria-label="Access keys" className="max-h-72" />				<SelectableList.Empty>No access keys found.</SelectableList.Empty>			</SelectableList.Root>			<p className="text-muted text-sm">				query: <span>{`“${query}”`}</span>			</p>		</div>	);}

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 string label).
  • optionLabelText(option): that plain text for one option; reuse it in a custom filter predicate 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 for selectAllChecked.

API Reference

SelectableList.Root

Accepts all <div> props, plus:

PropTypeDefaultDescription
optionsSelectableListOption[]The full set of selectable rows.
value?string[]Controlled selected values. Pair with onValueChange.
defaultValue?string[]Initial selected values for the uncontrolled case.
onValueChange?(values: string[]) => voidCalled with the next selection whenever it changes.
query?stringControlled filter query. Pair with onQueryChange.
defaultQuery?stringInitial filter query for the uncontrolled case.
onQueryChange?(query: string) => voidCalled with the next query whenever it changes (e.g. typing in SelectableList.Filter).
filter?(option: SelectableListOption, query: string) => booleanCustom filter predicate, called per option with the raw query; return true to keep the option. Defaults to a trimmed, case-insensitive substring match over each option's plain text (labelText, or a string label).

SelectableListOption

FieldTypeDescription
valuestringStable, unique selection value (flows through value/onValueChange).
labelReactNodeThe title — rendered in the default row and, as the checkbox's <label>, its accessible name (keep readable text inside).
labelTextstringPlain-text form of the label — what the default filter matches. Optional for a string label; required for a rich label.
description?ReactNodeOptional de-emphasized sub-line under the title in the default row.
disabled?booleanRenders the row dimmed and excludes it from toggling and "select all".

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:

PropTypeDefaultDescription
aria-label?string"Filter"Accessible name for the filter input. Prefer passing a specific one (e.g. "Filter access keys").

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:

PropTypeDefaultDescription
childrenReactNodeThe visible label (e.g. "Select all").

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.

Data AttributeValueDescription
data-slot"selectable-list-select-all"On the header <label>.
data-slot"selectable-list-select-all-label"On the <span> wrapping children.

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:

PropTypeDefaultDescription
children?(option: SelectableListOption) => ReactElement | nullRender-prop for custom row content, called per filtered option; return null to skip a row. Defaults to a title + description row.

SelectableList.VirtualViewport

The windowed grid — same surface as Viewport, plus the virtualizer knobs:

PropTypeDefaultDescription
children?(option: SelectableListOption) => ReactElement | nullRender-prop for custom row content (only the visible window mounts).
estimateItemHeight?number44Estimated item height (px) used to seed the virtualizer before measure.
overscan?number8Rows rendered beyond the visible window on each side.

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:

PropTypeDefaultDescription
valuestringThe option's selection value; selection and disabled state are read from the list.
childrenReactNodeItem content (e.g. ItemTitle + ItemDescription, or your own; interactive content allowed).

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).

ComponentBase props
ItemTitleAll Choice.Label props.
ItemDescriptionAll Choice.Description props.

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.

Data AttributeValueDescription
data-slot"selectable-list-empty"On the live-region element.
data-slot"selectable-list-empty-label"On the <span> wrapping children. Present only while the message shows.