Skip to main content

List

A scrollable list of clickable rows: the action / navigation counterpart to SelectableList. Compose List.Item rows directly inside a List.Root; each is a <button> (with onClick) or your own element via asChild (e.g. an <a>), with an optional current highlight. It shares SelectableList's look (styled like the MultiSelect popover, with rows that highlight on hover / selection as an inset, rounded pill inside a bordered bg-popover container) and suits an account switcher or an SSO provider picker. It renders every row by default; for long lists, swap Root → VirtualRoot to window them.

import { List } from "@ngrok/mantle/list";import { useState } from "react"; function Example() {	const [activeId, setActiveId] = useState("account-2"); 	return (		<List.Root aria-label="Your accounts" className="max-h-80">			{accounts.map((account) => (				<List.Item					key={account.id}					current={account.id === activeId}					onClick={() => setActiveId(account.id)}				>					<List.ItemTitle>{account.name}</List.ItemTitle>					<List.ItemDescription>						{account.plan} · {account.memberCount} members					</List.ItemDescription>				</List.Item>			))}		</List.Root>	);}

A minimal version (three rows, no current state):

import { List } from "@ngrok/mantle/list"; const accounts = [	{ id: "personal", name: "Cody Price", plan: "All Subscription" },	{ id: "corp", name: "corp@ngrok.com", plan: "Enterprise Subscription" },	{ id: "dogfood", name: "dogfood@ngrok.com", plan: "Free" },]; <List.Root aria-label="Switch account" className="max-h-80">	{accounts.map((account) => (		<List.Item key={account.id} onClick={() => {}}>			<List.ItemTitle>{account.name}</List.ItemTitle>			<List.ItemDescription>{account.plan}</List.ItemDescription>		</List.Item>	))}</List.Root>;

The same three rows hand-built without List, with the same chrome and no virtualization (fine for short, static lists):

import { cx } from "@ngrok/mantle/cx"; const accounts = [	{ id: "personal", name: "Cody Price", plan: "All Subscription" },	{ id: "corp", name: "corp@ngrok.com", plan: "Enterprise Subscription" },	{ id: "dogfood", name: "dogfood@ngrok.com", plan: "Free" },]; <ul className="border-popover bg-popover scrollbar m-0 max-h-80 list-none overflow-y-auto overflow-x-hidden rounded-md border px-0 py-1">	{accounts.map((account) => (		<li key={account.id} className="px-1 pb-px last:pb-0">			<button				type="button"				onClick={() => {}}				className={cx(					"flex w-full cursor-pointer flex-col gap-0.5 rounded-md bg-transparent px-2 py-1.5 text-left text-sm",					"hover:bg-active-menu-item",					"focus-visible:bg-active-menu-item focus-visible:outline-hidden",				)}			>				<span className="text-strong font-medium">{account.name}</span>				<span className="text-body leading-4">{account.plan}</span>			</button>		</li>	))}</ul>;

Composition

List.Root   (or .VirtualRoot)└── List.Item    ├── List.ItemTitle    └── List.ItemDescription
  • Root renders the rounded, scrollable bg-popover container and its Item children (all of them, non-virtualized). Reads each item's current / disabled to style its row. Bound its height (max-h-*, h-*, or min-h-0 flex-1 in a flex parent) so long lists scroll.
  • VirtualRoot is the drop-in windowed variant: same Item children, only the visible slice mounted. See Virtualization.
  • Item is a clickable row: a <button> by default (onClick), or your own element via asChild. current gives the tinted pill.
  • ItemTitle / ItemDescription are optional styled spans for the emphasized title and de-emphasized sub-line.

Virtualization

List.Root renders every row: simplest, and right for the short lists most action/navigation menus are. For a long list (hundreds+ of rows), swap in List.VirtualRoot: it windows the same Item children via @tanstack/react-virtual, mounting only what's visible. The call site is otherwise identical: same children, same props. (@tanstack/react-virtual ships with every list entrypoint; it is small, a few kB gzipped, and does no windowing work until a VirtualRoot renders.)

On the server, VirtualRoot renders the first slice of rows, sized by estimateItemHeight and overscan. The rest mount after hydration measures the viewport. Root 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 Root.

import { List } from "@ngrok/mantle/list";import { useState } from "react"; // `VirtualRoot` is a drop-in for `Root` — same `Item` children, same props.function Example() {	const [activeId, setActiveId] = useState("account-2"); 	return (		<List.VirtualRoot aria-label="Your accounts" className="max-h-80">			{accounts.map((account) => (				<List.Item					key={account.id}					current={account.id === activeId}					onClick={() => setActiveId(account.id)}				>					<List.ItemTitle>{account.name}</List.ItemTitle>					<List.ItemDescription>						{account.plan} · {account.memberCount} members					</List.ItemDescription>				</List.Item>			))}		</List.VirtualRoot>	);}

Windowing trade-off. Off-screen rows aren't in the DOM, so Tab and browser find-in-page only reach mounted rows (arrow keys still span the full list; they mount the target row before focusing it). That is the right trade for a large enough list. Prefer Root until a list is actually long.

Polymorphism

Render rows as links by passing asChild and an <a>: the row chrome (hover pill, keyboard-focus tint) applies to your element, so clicking navigates. Rows compose freely: each one here is a MediaObject with the provider icon on the left and the name over a de-emphasized owner on the right (an SSO identity-provider picker, as a list rather than a stack of cards).

import { cx } from "@ngrok/mantle/cx";import { MediaObject } from "@ngrok/mantle/media-object";import { List } from "@ngrok/mantle/list"; <List.Root aria-label="Identity providers" className="max-h-72">	{providers.map((provider) => (		<List.Item key={provider.id} asChild className="py-2.5">			<a href={`#${provider.id}`}>				<MediaObject.Root className="items-center gap-3">					<MediaObject.Media>						<provider.Icon weight="duotone" className={cx("size-8", provider.tint)} />					</MediaObject.Media>					<MediaObject.Content className="flex flex-col gap-0.5">						<List.ItemTitle>{provider.name}</List.ItemTitle>						<List.ItemDescription>{provider.owner}</List.ItemDescription>					</MediaObject.Content>				</MediaObject.Root>			</a>		</List.Item>	))}</List.Root>;

Accessibility

The list is a role="list" of role="listitem" rows (the tag is a <div>; per WAI-ARIA the role, not the tag, carries the semantics); each Item is a focusable <button> (or your asChild element) inside its row. It is a non-selecting list, not a selection widget; that's SelectableList's job. Set an aria-label (or aria-labelledby) on the Root so the list has an accessible name. The current row is announced via aria-current, not just tinted. Disabled rows are dimmed: a <button> row sets the native disabled, while an asChild row is conveyed with aria-disabled, removed from the tab order, and swallows clicks. For a genuinely disabled link, prefer omitting or removing the href.

Keyboard: rows keep their native tab order, and Arrow↑/Arrow↓ / Home / End also move focus between rows, skipping disabled ones (no wrap; arrowing past the last row holds). The focused row lights up with the same tint as hover instead of drawing a focus ring on the item, matching the SelectableList active row. Arrow navigation works under VirtualRoot too (a jump scrolls the target row into view and mounts it before moving focus), though off-screen rows still aren't reachable by Tab alone (see Virtualization).

API Reference

List.Root

The non-virtualized container. Accepts all <div> props, plus:

PropTypeDefaultDescription
childrenReactNodeList.Item rows (all are rendered).

Bound the height (e.g. className="max-h-80") so long lists scroll.

List.VirtualRoot

The windowed container — same surface as Root (compose the same Item children), plus the virtualizer knobs:

PropTypeDefaultDescription
childrenReactNodeList.Item rows (only the visible window is mounted).
estimateItemHeight?number44Estimated item height (px) used to seed the virtualizer before measure. With overscan, it also sizes the server-rendered first slice.
overscan?number8Rows rendered beyond the visible window on each side.

List.Item

A clickable row. Accepts all <button> props (minus type), plus:

PropTypeDefaultDescription
current?booleanfalseMarks the row as the current one (tinted pill) and sets aria-current, so the state is announced, not just shown. Omit for a plain action/navigation list.
disabled?booleanfalseDisables the row (dimmed). disabled on the button; with asChild, aria-disabled + removal from the tab order, and clicks are swallowed — prefer omitting href for a disabled link.
asChild?booleanfalseRender the row as the provided child element (e.g. an <a>) instead of a <button>.

List.ItemTitle / List.ItemDescription

Styled <span>s for the title and de-emphasized sub-line inside an Item. Both accept all <span> props.