Development
Production
Preview (disabled)
import { ListBox, ListBoxItem } from "@/components/ui/list-box" ;
export default function ListBoxDemo () {
return (
< ListBox
aria-label = "Environment"
selectionMode = "single"
defaultSelectedKeys = {[ "staging" ]}
className = "w-56"
>
< ListBoxItem id = "development" >Development</ ListBoxItem >
< ListBoxItem id = "staging" >Staging</ ListBoxItem >
< ListBoxItem id = "production" >Production</ ListBoxItem >
< ListBoxItem id = "preview" isDisabled >
Preview (disabled)
</ ListBoxItem >
</ ListBox >
);
}
CLI Manual
$ pnpm dlx shadcn@latest add @desyne/list-box
The CLI installs dependencies and any other components this one uses.
import {
ListBox,
ListBoxItem,
ListBoxItemDescription,
ListBoxItemLabel,
ListBoxSection,
} from "@/components/ui/list-box" ;
< ListBox aria-label = "Environment" selectionMode = "single" >
< ListBoxItem id = "development" >Development</ ListBoxItem >
< ListBoxItem id = "staging" >Staging</ ListBoxItem >
< ListBoxItem id = "production" >Production</ ListBoxItem >
</ ListBox >
Options, not controls
List box items are role="option" and can't contain interactive elements
like buttons, checkboxes or links inside them. If each row needs its own
actions, use a Grid List instead.
List Box — choosing one or more values from a visible list: a picker panel, a sidebar filter, a transfer list.
Select / Combo Box — the same list in a popover, to save space in forms.
Grid List — rows that hold buttons, menus or checkboxes, or need drag handles.
Menu — a list of commands, not a value.
Checkbox Group / Radio Group — a few options inside a form.
< ListBox > { /* role="listbox" */ }
< ListBoxSection title = "…" > { /* optional group with a heading */ }
< ListBoxItem id = "…" > { /* role="option" */ }
< Icon />
< ListBoxItemLabel /> { /* accessible name */ }
< ListBoxItemDescription /> { /* accessible description */ }
</ ListBoxItem > { /* checkmark added when selected */ }
</ ListBoxSection >
</ ListBox >
Part Renders Notes ListBox<div role="listbox">Scrollable, bordered panel (bg-popover). Shows renderEmptyState when empty. ListBoxItem<div role="option">Needs a unique id (or comes from items). Adds a trailing check icon when selected. Plain-string children become the textValue. ListBoxItemLabel<span slot="label">Primary text of a rich item, truncated. ListBoxItemDescription<span slot="description">Secondary text, muted. ListBoxSection<section role="group">Groups items under an optional title, with a divider between sections. ListBoxLoadMoreItem<div>From react-aria-components. Triggers onLoadMore when scrolled into view.
selectionMode is none by default (a static or actionable list), single or multiple.
none single multiple
US East (Virginia)
US West (California)
Asia Pacific (Singapore)
Asia Pacific (Sydney)
import { useState } from "react" ;
import type { SelectionMode } from "react-aria-components" ;
import { ListBox, ListBoxItem } from "@/components/ui/list-box" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
import { ToggleButtonGroup } from "@/components/ui/toggle-button-group" ;
export default function ListBoxSelectionModes () {
const [ mode , setMode ] = useState < SelectionMode >( "multiple" );
return (
< div className = "flex w-full max-w-64 flex-col gap-3" >
< ToggleButtonGroup
aria-label = "Selection mode"
variant = "segmented"
size = "sm"
selectedKeys = {[mode]}
onSelectionChange = {( keys ) => setMode ([ ... keys][ 0 ] as SelectionMode )}
disallowEmptySelection
>
< ToggleButton id = "none" >none</ ToggleButton >
< ToggleButton id = "single" >single</ ToggleButton >
< ToggleButton id = "multiple" >multiple</ ToggleButton >
</ ToggleButtonGroup >
< ListBox
key = {mode}
aria-label = "Regions"
selectionMode = {mode}
defaultSelectedKeys = {mode === "none" ? [] : [ "fra" ]}
>
< ListBoxItem id = "iad" >US East (Virginia)</ ListBoxItem >
< ListBoxItem id = "sfo" >US West (California)</ ListBoxItem >
< ListBoxItem id = "fra" >Europe (Frankfurt)</ ListBoxItem >
< ListBoxItem id = "sin" >Asia Pacific (Singapore)</ ListBoxItem >
< ListBoxItem id = "syd" >Asia Pacific (Sydney)</ ListBoxItem >
</ ListBox >
</ div >
);
}
selectedKeys and onSelectionChange own the selection. The value is a Selection: a Set of keys, or the string "all" after ⌘ /Ctrl +A or setSelected("all"), so handle both.
Mushrooms
Roasted peppers
Red onion
Chili flakes
Black olives, Fresh basil
Select all Clear
import { useState } from "react" ;
import type { Selection } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { ListBox, ListBoxItem } from "@/components/ui/list-box" ;
const toppings = [
{ id: "mushroom" , name: "Mushrooms" },
{ id: "olive" , name: "Black olives" },
{ id: "pepper" , name: "Roasted peppers" },
{ id: "onion" , name: "Red onion" },
{ id: "basil" , name: "Fresh basil" },
{ id: "chili" , name: "Chili flakes" },
];
export default function ListBoxControlled () {
const [ selected , setSelected ] = useState < Selection >(
new Set ([ "olive" , "basil" ]),
);
const names =
selected === "all"
? toppings. map (( t ) => t.name)
: toppings. filter (( t ) => selected. has (t.id)). map (( t ) => t.name);
return (
< div className = "flex w-full max-w-64 flex-col gap-3" >
< ListBox
aria-label = "Toppings"
items = {toppings}
selectionMode = "multiple"
selectedKeys = {selected}
onSelectionChange = {setSelected}
>
{( item ) => < ListBoxItem >{item.name}</ ListBoxItem >}
</ ListBox >
< p className = "text-muted-foreground text-xs" >
{names. length ? names. join ( ", " ) : "No toppings" }
</ p >
< div className = "flex gap-2" >
< Button size = "xs" variant = "outline" onPress = {() => setSelected ( "all" )}>
Select all
</ Button >
< Button
size = "xs"
variant = "outline"
onPress = {() => setSelected ( new Set ())}
>
Clear
</ Button >
</ div >
</ div >
);
}
selectionBehavior="replace" makes a click select only that item, like a file browser. ⌘ /Ctrl and Shift add to or extend the selection; arrow keys move the selection with focus. The default toggle adds and removes items on every click.
Click selects one file. Hold ⌘/Ctrl or Shift to select more.
import { FileTextIcon } from "lucide-react" ;
import { ListBox, ListBoxItem } from "@/components/ui/list-box" ;
const files = [
"brand-guidelines.pdf" ,
"q3-roadmap.key" ,
"customer-interviews.docx" ,
"pricing-model.xlsx" ,
"launch-checklist.md" ,
"press-kit.zip" ,
];
export default function ListBoxSelectionBehavior () {
return (
< div className = "flex w-full max-w-64 flex-col gap-2" >
< ListBox
aria-label = "Files"
selectionMode = "multiple"
selectionBehavior = "replace"
defaultSelectedKeys = {[ "q3-roadmap.key" ]}
>
{files. map (( file ) => (
< ListBoxItem key = {file} id = {file} textValue = {file}>
< FileTextIcon />
{file}
</ ListBoxItem >
))}
</ ListBox >
< p className = "text-muted-foreground text-xs" >
Click selects one file. Hold ⌘/Ctrl or Shift to select more.
</ p >
</ div >
);
}
disabledKeys keeps items visible but not focusable or selectable. isDisabled on a ListBoxItem does the same for one item.
Hobby
Pro
Enterprise (contact sales)
import { ListBox, ListBoxItem } from "@/components/ui/list-box" ;
export default function ListBoxDisabled () {
return (
< ListBox
aria-label = "Plan"
selectionMode = "single"
defaultSelectedKeys = {[ "team" ]}
disabledKeys = {[ "enterprise" ]}
className = "w-full max-w-64"
>
< ListBoxItem id = "hobby" >Hobby</ ListBoxItem >
< ListBoxItem id = "pro" >Pro</ ListBoxItem >
< ListBoxItem id = "team" >Team</ ListBoxItem >
< ListBoxItem id = "enterprise" >Enterprise (contact sales)</ ListBoxItem >
</ ListBox >
);
}
ListBoxSection groups items under a heading. Selection works across sections.
Invite members
Manage billing
import { ListBox, ListBoxItem, ListBoxSection } from "@/components/ui/list-box" ;
export default function ListBoxSections () {
return (
< ListBox
aria-label = "Permissions"
selectionMode = "multiple"
defaultSelectedKeys = {[ "read" , "comment" ]}
className = "w-60"
>
< ListBoxSection title = "Content" >
< ListBoxItem id = "read" >Read</ ListBoxItem >
< ListBoxItem id = "comment" >Comment</ ListBoxItem >
< ListBoxItem id = "edit" >Edit</ ListBoxItem >
</ ListBoxSection >
< ListBoxSection title = "Admin" >
< ListBoxItem id = "invite" >Invite members</ ListBoxItem >
< ListBoxItem id = "billing" >Manage billing</ ListBoxItem >
</ ListBoxSection >
</ ListBox >
);
}
Pass nested data to items and render sections with a function; each section takes its own items. Every object needs an id (or key).
web-app
docs-site
admin-console
billing-service
auth-service
terraform-modules
ci-runners
import { ListBox, ListBoxItem, ListBoxSection } from "@/components/ui/list-box" ;
const groups = [
{
id: "frontend" ,
name: "Frontend" ,
children: [
{ id: "web" , name: "web-app" },
{ id: "docs" , name: "docs-site" },
{ id: "admin" , name: "admin-console" },
],
},
{
id: "backend" ,
name: "Backend" ,
children: [
{ id: "api" , name: "api-gateway" },
{ id: "billing" , name: "billing-service" },
{ id: "auth" , name: "auth-service" },
],
},
{
id: "infra" ,
name: "Infrastructure" ,
children: [
{ id: "terraform" , name: "terraform-modules" },
{ id: "ci" , name: "ci-runners" },
],
},
];
export default function ListBoxDynamicSections () {
return (
< ListBox
aria-label = "Repositories"
items = {groups}
selectionMode = "multiple"
defaultSelectedKeys = {[ "api" ]}
className = "max-h-72 w-full max-w-64"
>
{( group ) => (
< ListBoxSection id = {group.id} title = {group.name} items = {group.children}>
{( repo ) => < ListBoxItem >{repo.name}</ ListBoxItem >}
</ ListBoxSection >
)}
</ ListBox >
);
}
Compose icons with ListBoxItemLabel and ListBoxItemDescription. They're wired to the option's accessible name and description. Set textValue for typeahead when children aren't a plain string.
Viewer Can view projects and dashboards Editor Can create and edit content Admin Full access, including billing and members
import {
EyeIcon,
MessageSquareIcon,
PencilIcon,
ShieldIcon,
} from "lucide-react" ;
import {
ListBox,
ListBoxItem,
ListBoxItemDescription,
ListBoxItemLabel,
} from "@/components/ui/list-box" ;
const roles = [
{
id: "viewer" ,
icon: EyeIcon,
name: "Viewer" ,
description: "Can view projects and dashboards" ,
},
{
id: "commenter" ,
icon: MessageSquareIcon,
name: "Commenter" ,
description: "Can view and leave comments" ,
},
{
id: "editor" ,
icon: PencilIcon,
name: "Editor" ,
description: "Can create and edit content" ,
},
{
id: "admin" ,
icon: ShieldIcon,
name: "Admin" ,
description: "Full access, including billing and members" ,
},
];
export default function ListBoxRichItems () {
return (
< ListBox
aria-label = "Role"
items = {roles}
selectionMode = "single"
defaultSelectedKeys = {[ "editor" ]}
className = "w-full max-w-72"
>
{( role ) => (
< ListBoxItem textValue = {role.name} className = "items-start" >
< role.icon className = "mt-0.5" />
< span className = "flex min-w-0 flex-col gap-0.5" >
< ListBoxItemLabel >{role.name}</ ListBoxItemLabel >
< ListBoxItemDescription >{role.description}</ ListBoxItemDescription >
</ span >
</ ListBoxItem >
)}
</ ListBox >
);
}
With selectionMode="none", onAction turns items into actions (open, run, navigate). To combine actions with selection, use selectionBehavior="replace": a click selects, and a double click or Enter triggers onAction.
Click or press Enter to open a file.
import { FileCodeIcon, FileTextIcon, ImageIcon } from "lucide-react" ;
import { useState } from "react" ;
import type { Key } from "react-aria-components" ;
import { ListBox, ListBoxItem } from "@/components/ui/list-box" ;
const recent = [
{ id: "readme" , name: "README.md" , icon: FileTextIcon },
{ id: "config" , name: "next.config.mjs" , icon: FileCodeIcon },
{ id: "hero" , name: "hero@2x.png" , icon: ImageIcon },
{ id: "layout" , name: "app/layout.tsx" , icon: FileCodeIcon },
];
export default function ListBoxActions () {
const [ opened , setOpened ] = useState < Key | null >( null );
return (
< div className = "flex w-full max-w-64 flex-col gap-2" >
< ListBox
aria-label = "Recent files"
items = {recent}
onAction = {( key ) => setOpened (key)}
>
{( file ) => (
< ListBoxItem textValue = {file.name}>
< file.icon />
{file.name}
</ ListBoxItem >
)}
</ ListBox >
< p className = "text-muted-foreground text-xs" >
{opened
? `Opened ${ recent . find (( f ) => f . id === opened )?. name }`
: "Click or press Enter to open a file." }
</ p >
</ div >
);
}
Give items an href (and optionally target) to render them as links. They navigate with a click or Enter , and work with client-side routers through React Aria's RouterProvider.
import {
BookOpenIcon,
LayoutGridIcon,
LifeBuoyIcon,
MessagesSquareIcon,
} from "lucide-react" ;
import { ListBox, ListBoxItem } from "@/components/ui/list-box" ;
export default function ListBoxLinks () {
return (
< ListBox aria-label = "Resources" className = "w-full max-w-60" >
< ListBoxItem href = "/docs" textValue = "Documentation" >
< BookOpenIcon />
Documentation
</ ListBoxItem >
< ListBoxItem href = "/docs/components" textValue = "Components" >
< LayoutGridIcon />
Components
</ ListBoxItem >
< ListBoxItem
href = "https://github.com/adobe/react-spectrum/discussions"
target = "_blank"
textValue = "Community"
>
< MessagesSquareIcon />
Community
</ ListBoxItem >
< ListBoxItem
href = "https://react-spectrum.adobe.com/react-aria/"
target = "_blank"
textValue = "React Aria"
>
< LifeBuoyIcon />
React Aria
</ ListBoxItem >
</ ListBox >
);
}
renderEmptyState renders when there are no items. The list gets data-empty and centered, muted text.
No notification rules yet.
import { BellOffIcon } from "lucide-react" ;
import { ListBox } from "@/components/ui/list-box" ;
export default function ListBoxEmpty () {
return (
< ListBox
aria-label = "Notification rules"
className = "w-full max-w-64"
renderEmptyState = {() => (
< div className = "flex flex-col items-center gap-2" >
< BellOffIcon className = "size-5 text-muted-foreground/60" />
No notification rules yet.
</ div >
)}
>
{[]}
</ ListBox >
);
}
Put the items in a Collection followed by ListBoxLoadMoreItem. When the sentinel scrolls into view it calls onLoadMore; useAsyncList manages pages, cursors and loading state. Give the list a fixed height so it scrolls.
import {
Collection,
ListBoxLoadMoreItem,
useAsyncList,
} from "react-aria-components" ;
import { ListBox, ListBoxItem } from "@/components/ui/list-box" ;
import { Spinner } from "@/components/ui/spinner" ;
const packages = [
"react" ,
"react-dom" ,
"next" ,
"typescript" ,
"tailwindcss" ,
"vite" ,
"zod" ,
"date-fns" ,
"lodash-es" ,
"axios" ,
"express" ,
"prisma" ,
"drizzle-orm" ,
"vitest" ,
"playwright" ,
"eslint" ,
"prettier" ,
"biome" ,
"turbo" ,
"esbuild" ,
"rollup" ,
"webpack" ,
"postcss" ,
"autoprefixer" ,
"clsx" ,
"tailwind-merge" ,
"lucide-react" ,
"framer-motion" ,
"zustand" ,
"jotai" ,
"swr" ,
"@tanstack/react-query" ,
"react-hook-form" ,
"react-aria-components" ,
"@internationalized/date" ,
"sonner" ,
"recharts" ,
"shiki" ,
"unified" ,
"remark" ,
];
const PAGE_SIZE = 10 ;
export default function ListBoxAsync () {
const list = useAsyncList <{ name : string }>({
async load ({ cursor }) {
// Replace with a real request, e.g. fetch(`/api/packages?cursor=${cursor}`).
const start = cursor ? Number (cursor) : 0 ;
await new Promise (( resolve ) => setTimeout (resolve, 700 ));
const page = packages. slice (start, start + PAGE_SIZE );
const next = start + PAGE_SIZE ;
return {
items: page. map (( name ) => ({ name })),
cursor: next < packages. length ? String (next) : undefined ,
};
},
});
return (
< ListBox
aria-label = "Packages"
selectionMode = "multiple"
className = "h-64 w-full max-w-64"
renderEmptyState = {() =>
list.isLoading ? (
< Spinner label = "Loading packages" className = "mx-auto" />
) : (
"No packages found."
)
}
>
< Collection items = {list.items}>
{( item ) => (
< ListBoxItem id = {item.name} className = "font-mono text-xs" >
{item.name}
</ ListBoxItem >
)}
</ Collection >
< ListBoxLoadMoreItem
onLoadMore = {list.loadMore}
isLoading = {list.loadingState === "loadingMore" }
className = "flex justify-center py-2"
>
< Spinner label = "Loading more packages" />
</ ListBoxLoadMoreItem >
</ ListBox >
);
}
Pass dragAndDropHooks from useDragAndDrop and update your data in onReorder. renderDropIndicator with React Aria's DropIndicator draws the insertion line. Dragging works with mouse, touch, keyboard and screen readers.
Overview
Pricing
Customers
Changelog
Careers
Drag to reorder, or focus an item and press Enter to start a keyboard drag.
import {
DropIndicator,
useDragAndDrop,
useListData,
} from "react-aria-components" ;
import { ListBox, ListBoxItem } from "@/components/ui/list-box" ;
export default function ListBoxReorder () {
const list = useListData ({
initialItems: [
{ id: "overview" , name: "Overview" },
{ id: "pricing" , name: "Pricing" },
{ id: "customers" , name: "Customers" },
{ id: "changelog" , name: "Changelog" },
{ id: "careers" , name: "Careers" },
],
});
const { dragAndDropHooks } = useDragAndDrop ({
getItems : ( keys ) =>
[ ... keys]. map (( key ) => ({ "text/plain" : list. getItem (key)?.name ?? "" })),
onReorder ( e ) {
if (e.target.dropPosition === "before" ) {
list. moveBefore (e.target.key, e.keys);
} else if (e.target.dropPosition === "after" ) {
list. moveAfter (e.target.key, e.keys);
}
},
renderDropIndicator : ( target ) => (
< DropIndicator
target = {target}
className = "-my-px h-0.5 rounded-full data-drop-target:bg-brand"
/>
),
});
return (
< div className = "flex w-full max-w-60 flex-col gap-2" >
< ListBox
aria-label = "Navigation order"
items = {list.items}
selectionMode = "multiple"
dragAndDropHooks = {dragAndDropHooks}
>
{( item ) => (
< ListBoxItem className = "data-dragging:opacity-50" >
{item.name}
</ ListBoxItem >
)}
</ ListBox >
< p className = "text-muted-foreground text-xs" >
Drag to reorder, or focus an item and press Enter to start a keyboard
drag.
</ p >
</ div >
);
}
layout="grid" switches keyboard navigation to two dimensions, so arrow keys move up, down, left and right across a CSS grid. Here tiles hide the trailing checkmark and show selection with a ring instead.
import {
BriefcaseIcon,
CameraIcon,
CodeIcon,
CoffeeIcon,
FlaskConicalIcon,
GlobeIcon,
HeartIcon,
MusicIcon,
PaletteIcon,
RocketIcon,
ShoppingBagIcon,
SparklesIcon,
} from "lucide-react" ;
import { ListBox, ListBoxItem } from "@/components/ui/list-box" ;
const icons = [
{ id: "rocket" , label: "Rocket" , icon: RocketIcon },
{ id: "code" , label: "Code" , icon: CodeIcon },
{ id: "palette" , label: "Palette" , icon: PaletteIcon },
{ id: "flask" , label: "Lab" , icon: FlaskConicalIcon },
{ id: "globe" , label: "Globe" , icon: GlobeIcon },
{ id: "briefcase" , label: "Briefcase" , icon: BriefcaseIcon },
{ id: "camera" , label: "Camera" , icon: CameraIcon },
{ id: "music" , label: "Music" , icon: MusicIcon },
{ id: "coffee" , label: "Coffee" , icon: CoffeeIcon },
{ id: "heart" , label: "Heart" , icon: HeartIcon },
{ id: "bag" , label: "Shopping" , icon: ShoppingBagIcon },
{ id: "sparkles" , label: "Sparkles" , icon: SparklesIcon },
];
export default function ListBoxGridLayout () {
return (
< ListBox
aria-label = "Workspace icon"
items = {icons}
layout = "grid"
selectionMode = "single"
disallowEmptySelection
defaultSelectedKeys = {[ "rocket" ]}
className = "grid w-fit grid-cols-4 gap-1"
>
{( item ) => (
< ListBoxItem
textValue = {item.label}
aria-label = {item.label}
className = "size-12 justify-center p-0 data-selected:ring-2 data-selected:ring-brand [&>span]:hidden"
>
< item.icon className = "size-5 text-foreground" />
</ ListBoxItem >
)}
</ ListBox >
);
}
React Aria's Autocomplete connects a SearchField to the list: typing filters with useFilter, and arrow keys move through results while focus stays in the input.
Los Angeles UTC−08:00
Denver UTC−07:00
Chicago UTC−06:00
New York UTC−05:00
São Paulo UTC−03:00
London UTC+00:00
Helsinki UTC+02:00
Dubai UTC+04:00
Kolkata UTC+05:30
Kathmandu UTC+05:45
Singapore UTC+08:00
Tokyo UTC+09:00
Sydney UTC+10:00
Auckland UTC+12:00
import { Autocomplete, useFilter } from "react-aria-components" ;
import {
ListBox,
ListBoxItem,
ListBoxItemDescription,
ListBoxItemLabel,
} from "@/components/ui/list-box" ;
import { SearchField } from "@/components/ui/search-field" ;
const timezones = [
{ id: "America/Los_Angeles" , city: "Los Angeles" , offset: "UTC−08:00" },
{ id: "America/Denver" , city: "Denver" , offset: "UTC−07:00" },
{ id: "America/Chicago" , city: "Chicago" , offset: "UTC−06:00" },
{ id: "America/New_York" , city: "New York" , offset: "UTC−05:00" },
{ id: "America/Sao_Paulo" , city: "São Paulo" , offset: "UTC−03:00" },
{ id: "Europe/London" , city: "London" , offset: "UTC+00:00" },
{ id: "Europe/Berlin" , city: "Berlin" , offset: "UTC+01:00" },
{ id: "Europe/Helsinki" , city: "Helsinki" , offset: "UTC+02:00" },
{ id: "Asia/Dubai" , city: "Dubai" , offset: "UTC+04:00" },
{ id: "Asia/Kolkata" , city: "Kolkata" , offset: "UTC+05:30" },
{ id: "Asia/Kathmandu" , city: "Kathmandu" , offset: "UTC+05:45" },
{ id: "Asia/Singapore" , city: "Singapore" , offset: "UTC+08:00" },
{ id: "Asia/Tokyo" , city: "Tokyo" , offset: "UTC+09:00" },
{ id: "Australia/Sydney" , city: "Sydney" , offset: "UTC+10:00" },
{ id: "Pacific/Auckland" , city: "Auckland" , offset: "UTC+12:00" },
];
export default function ListBoxRecipeFilterable () {
const { contains } = useFilter ({ sensitivity: "base" });
return (
< div className = "flex w-full max-w-72 flex-col gap-2 rounded-lg border bg-popover p-2 shadow-xs" >
< Autocomplete filter = {contains}>
< SearchField
aria-label = "Search time zones"
placeholder = "Search cities…"
size = "sm"
/>
< ListBox
aria-label = "Time zones"
items = {timezones}
selectionMode = "single"
defaultSelectedKeys = {[ "Europe/Berlin" ]}
className = "max-h-60 border-0 p-0"
renderEmptyState = {() => "No matching time zones." }
>
{( tz ) => (
< ListBoxItem textValue = {tz.city}>
< span className = "flex min-w-0 flex-1 items-baseline justify-between gap-2" >
< ListBoxItemLabel >{tz.city}</ ListBoxItemLabel >
< ListBoxItemDescription className = "tabular-nums" >
{tz.offset}
</ ListBoxItemDescription >
</ span >
</ ListBoxItem >
)}
</ ListBox >
</ Autocomplete >
</ div >
);
}
Two multi-select lists backed by useListData, with buttons to move the selection across. With selectionBehavior="replace", a click selects and onAction (double click or Enter ) moves a single item.
Available columns MRR
Country
Signed up
Last seen
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react" ;
import { type ReactNode, useState } from "react" ;
import { type Selection, useListData } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { ListBox, ListBoxItem } from "@/components/ui/list-box" ;
type Column = { id : string ; name : string };
const all : Column [] = [
{ id: "name" , name: "Name" },
{ id: "email" , name: "Email" },
{ id: "company" , name: "Company" },
{ id: "plan" , name: "Plan" },
{ id: "mrr" , name: "MRR" },
{ id: "country" , name: "Country" },
{ id: "signup" , name: "Signed up" },
{ id: "lastSeen" , name: "Last seen" },
];
function keysOf ( selection : Selection , items : Column []) {
return selection === "all" ? items. map (( i ) => i.id) : [ ... selection];
}
export default function ListBoxRecipeTransfer () {
const hidden = useListData ({ initialItems: all. slice ( 4 ) });
const shown = useListData ({ initialItems: all. slice ( 0 , 4 ) });
const [ hiddenSel , setHiddenSel ] = useState < Selection >( new Set ());
const [ shownSel , setShownSel ] = useState < Selection >( new Set ());
const move = (
from : typeof hidden,
to : typeof shown,
selection : Selection ,
clear : ( s : Selection ) => void ,
) => {
const keys = keysOf (selection, from.items);
const items = keys
. map (( k ) => from. getItem (k))
. filter (( i ) : i is Column => Boolean (i));
from. remove ( ... keys);
to. append ( ... items);
clear ( new Set ());
};
const panel = ( title : string , children : ReactNode ) => (
< div className = "flex min-w-0 flex-1 flex-col gap-1.5" >
< span className = "font-medium text-muted-foreground text-xs" >{title}</ span >
{children}
</ div >
);
return (
< div className = "flex w-full max-w-md items-center gap-2" >
{ panel (
"Available columns" ,
< ListBox
aria-label = "Available columns"
items = {hidden.items}
selectionMode = "multiple"
selectionBehavior = "replace"
selectedKeys = {hiddenSel}
onSelectionChange = {setHiddenSel}
onAction = {( key ) => move (hidden, shown, new Set ([key]), setHiddenSel)}
renderEmptyState = {() => "All columns shown" }
className = "h-56"
>
{( c ) => < ListBoxItem >{c.name}</ ListBoxItem >}
</ ListBox >,
)}
< div className = "flex flex-col gap-1 pt-5" >
< Button
size = "icon-sm"
variant = "outline"
aria-label = "Show selected columns"
isDisabled = {hiddenSel !== "all" && hiddenSel.size === 0 }
onPress = {() => move (hidden, shown, hiddenSel, setHiddenSel)}
>
< ChevronRightIcon />
</ Button >
< Button
size = "icon-sm"
variant = "outline"
aria-label = "Hide selected columns"
isDisabled = {shownSel !== "all" && shownSel.size === 0 }
onPress = {() => move (shown, hidden, shownSel, setShownSel)}
>
< ChevronLeftIcon />
</ Button >
</ div >
{ panel (
"Visible columns" ,
< ListBox
aria-label = "Visible columns"
items = {shown.items}
selectionMode = "multiple"
selectionBehavior = "replace"
selectedKeys = {shownSel}
onSelectionChange = {setShownSel}
onAction = {( key ) => move (shown, hidden, new Set ([key]), setShownSel)}
renderEmptyState = {() => "No columns" }
className = "h-56"
>
{( c ) => < ListBoxItem >{c.name}</ ListBoxItem >}
</ ListBox >,
)}
</ div >
);
}
Sections for suggested and team reviewers, Avatar s with two-line items, and a footer that reflects the controlled selection.
Request review
MP Maya Patel Owns 12 changed files LF Leo Fischer Recently edited billing/
AS Ana Souza Frontend
KW Kenji Watanabe Platform
SO Sam Okafor Payments
IL Inès Laurent Design systems
1 selected Request
import { useState } from "react" ;
import type { Selection } from "react-aria-components" ;
import { Avatar } from "@/components/ui/avatar" ;
import { Button } from "@/components/ui/button" ;
import {
ListBox,
ListBoxItem,
ListBoxItemDescription,
ListBoxItemLabel,
ListBoxSection,
} from "@/components/ui/list-box" ;
const suggested = [
{ id: "maya" , name: "Maya Patel" , detail: "Owns 12 changed files" },
{ id: "leo" , name: "Leo Fischer" , detail: "Recently edited billing/" },
];
const team = [
{ id: "ana" , name: "Ana Souza" , detail: "Frontend" },
{ id: "kenji" , name: "Kenji Watanabe" , detail: "Platform" },
{ id: "sam" , name: "Sam Okafor" , detail: "Payments" },
{ id: "ines" , name: "Inès Laurent" , detail: "Design systems" },
];
const initials = ( name : string ) =>
name
. split ( " " )
. map (( n ) => n[ 0 ])
. join ( "" );
export default function ListBoxRecipeReviewers () {
const [ selected , setSelected ] = useState < Selection >( new Set ([ "maya" ]));
const count =
selected === "all" ? suggested. length + team. length : selected.size;
const renderPerson = ( p : ( typeof team)[ number ]) => (
< ListBoxItem key = {p.id} id = {p.id} textValue = {p.name}>
< Avatar size = "sm" colorful alt = {p.name} fallback = { initials (p.name)} />
< span className = "flex min-w-0 flex-col" >
< ListBoxItemLabel >{p.name}</ ListBoxItemLabel >
< ListBoxItemDescription >{p.detail}</ ListBoxItemDescription >
</ span >
</ ListBoxItem >
);
return (
< div className = "flex w-full max-w-72 flex-col overflow-hidden rounded-lg border bg-popover shadow-xs" >
< div className = "border-b px-3 py-2 font-medium text-sm" >
Request review
</ div >
< ListBox
aria-label = "Reviewers"
selectionMode = "multiple"
selectedKeys = {selected}
onSelectionChange = {setSelected}
className = "max-h-72 rounded-none border-0"
>
< ListBoxSection title = "Suggested" >
{suggested. map (renderPerson)}
</ ListBoxSection >
< ListBoxSection title = "Team" >{team. map (renderPerson)}</ ListBoxSection >
</ ListBox >
< div className = "flex items-center justify-between border-t px-3 py-2" >
< span className = "text-muted-foreground text-xs" >{count} selected</ span >
< Button size = "xs" isDisabled = {count === 0 }>
Request
</ Button >
</ div >
</ div >
);
}
The list is role="listbox" (with aria-multiselectable in multiple mode), and items are role="option" with aria-selected.
Always label the list with aria-label or aria-labelledby.
Focus is managed with a roving tab index: the list is one tab stop, and arrow keys move between items.
Typeahead jumps to the next item whose textValue starts with the typed characters.
Sections are role="group" labelled by their heading.
ListBoxItemLabel and ListBoxItemDescription become the option's aria-labelledby and aria-describedby.
Drag and drop exposes keyboard and screen reader interactions automatically, with announcements for each step.
Key Action Tab Moves focus into the list (to the selected or first item) and out again ↑ / ↓ Moves focus to the previous / next item (←/→ too in grid layout) Home / End Moves focus to the first / last item Page Up / Page Down Moves focus by a page Space Toggles selection of the focused item Enter Selects, or triggers onAction / follows the link Shift +↑ / Shift +↓ Extends the selection (multiple) ⌘ /Ctrl +A Selects all (multiple) Esc Clears the selection (unless escapeKeyBehavior="none") Any character Typeahead
On the ListBox:
Attribute Present when data-emptyThere are no items data-focused / data-focus-visibleThe list itself has focus / keyboard focus data-drop-targetSomething is being dragged over the list data-layoutAlways: stack or grid data-orientationAlways: vertical or horizontal
On each ListBoxItem (use group-data-*/item: inside it):
Attribute Present when data-selectedThe item is selected data-focused / data-focus-visibleThe item has focus (including virtual focus) / keyboard focus data-hovered / data-pressedHovered with a mouse / being pressed data-disabledThe item is disabled data-selection-modesingle or multipledata-allows-dragging / data-draggingDrag and drop is enabled / the item is being dragged data-drop-targetThe item is the current drop target
data-slotElement list-boxRoot list-box-itemEach item list-box-sectionEach section
listBoxItemStyles is the class string for items. Reuse it to make other option-like elements match:
import { listBoxItemStyles } from "@/components/ui/list-box" ;
The list has max-h-[inherit] and overflow-auto, so inside a popover it scrolls within the popover's height. Standalone, give it a max-h-* or h-*.
Items reserve pr-8 for the checkmark. Override with className when you hide it.
Icons inside items are sized to size-4 and muted unless they set their own size-* or text-* class.
className on ListBox and ListBoxItem also accepts a function of the render props.
Also accepts every prop of React Aria's ListBox .
Accept every prop of React Aria's Text . They set slot="label" and slot="description".
Select — this list in a popover, for one value in a form.
Combo Box — a searchable select.
Grid List — interactive rows with actions and checkboxes.
Tag Group — compact, removable selections.