ListBox
A list of interactive items with keyboard navigation and selection support. Supports single and multiple selection, sections, disabled items, and custom empty states.
The ListBox component displays a list of options and allows a user to select one or more of them. Built on React Aria's ListBox primitive with full keyboard navigation and accessibility.
Basic Usage
A simple list box with single selection. Use arrow keys to navigate and Enter or Space to select.
"use client";
import { ListBox, ListBoxItem } from "@repo/ui/components/list-box";
/* BASIC USAGE */
export const Example1 = () => {
return (
<ListBox aria-label="Favorite animal" selectionMode="single">
<ListBoxItem>Aardvark</ListBoxItem>
<ListBoxItem>Cat</ListBoxItem>
<ListBoxItem>Dog</ListBoxItem>
<ListBoxItem>Kangaroo</ListBoxItem>
<ListBoxItem>Panda</ListBoxItem>
<ListBoxItem>Snake</ListBoxItem>
</ListBox>
);
};
Controlled Single Selection
Use selectedKeys and onSelectionChange to control the selected item programmatically.
Selected: cat
"use client";
import { useState } from "react";
import type { Selection } from "react-aria-components";
import { ListBox, ListBoxItem } from "@repo/ui/components/list-box";
/* CONTROLLED SINGLE SELECTION */
export const Example2 = () => {
const [selected, setSelected] = useState<Selection>(new Set(["cat"]));
return (
<div className="flex flex-col gap-2">
<ListBox
aria-label="Favorite animal"
selectionMode="single"
selectedKeys={selected}
onSelectionChange={setSelected}
>
<ListBoxItem id="aardvark">Aardvark</ListBoxItem>
<ListBoxItem id="cat">Cat</ListBoxItem>
<ListBoxItem id="dog">Dog</ListBoxItem>
<ListBoxItem id="kangaroo">Kangaroo</ListBoxItem>
<ListBoxItem id="panda">Panda</ListBoxItem>
</ListBox>
<p className="text-muted-foreground text-sm">
Selected:{" "}
{selected === "all"
? "all"
: [...selected].join(", ") || "none"}
</p>
</div>
);
};
Multiple Selection
Set selectionMode="multiple" to allow selecting multiple items. Click to toggle, or hold Shift to select a range.
Selected: cheese, ham
"use client";
import { useState } from "react";
import type { Selection } from "react-aria-components";
import { ListBox, ListBoxItem } from "@repo/ui/components/list-box";
/* MULTIPLE SELECTION */
export const Example3 = () => {
const [selected, setSelected] = useState<Selection>(
new Set(["cheese", "ham"]),
);
return (
<div className="flex flex-col gap-2">
<ListBox
aria-label="Sandwich contents"
selectionMode="multiple"
selectedKeys={selected}
onSelectionChange={setSelected}
>
<ListBoxItem id="lettuce">Lettuce</ListBoxItem>
<ListBoxItem id="tomato">Tomato</ListBoxItem>
<ListBoxItem id="cheese">Cheese</ListBoxItem>
<ListBoxItem id="tuna">Tuna Salad</ListBoxItem>
<ListBoxItem id="egg">Egg Salad</ListBoxItem>
<ListBoxItem id="ham">Ham</ListBoxItem>
</ListBox>
<p className="text-muted-foreground text-sm">
Selected:{" "}
{selected === "all"
? "all"
: [...selected].join(", ") || "none"}
</p>
</div>
);
};
Sections
Use DropdownSection with a title to group items into labeled categories with sticky headers.
"use client";
import {
ListBox,
ListBoxItem,
DropdownSection,
} from "@repo/ui/components/list-box";
/* SECTIONS WITH HEADERS */
export const Example4 = () => {
return (
<ListBox aria-label="Sandwich contents" selectionMode="multiple">
<DropdownSection title="Veggies" items={undefined}>
<ListBoxItem id="lettuce">Lettuce</ListBoxItem>
<ListBoxItem id="tomato">Tomato</ListBoxItem>
<ListBoxItem id="onion">Onion</ListBoxItem>
</DropdownSection>
<DropdownSection title="Protein" items={undefined}>
<ListBoxItem id="ham">Ham</ListBoxItem>
<ListBoxItem id="tuna">Tuna</ListBoxItem>
<ListBoxItem id="tofu">Tofu</ListBoxItem>
</DropdownSection>
<DropdownSection title="Condiments" items={undefined}>
<ListBoxItem id="mayo">Mayonnaise</ListBoxItem>
<ListBoxItem id="mustard">Mustard</ListBoxItem>
<ListBoxItem id="ranch">Ranch</ListBoxItem>
</DropdownSection>
</ListBox>
);
};
Disabled Items
Use the disabledKeys prop to disable specific items. Disabled items cannot be selected or focused.
"use client";
import { ListBox, ListBoxItem } from "@repo/ui/components/list-box";
/* DISABLED ITEMS */
export const Example5 = () => {
return (
<ListBox
aria-label="Permissions"
selectionMode="single"
disabledKeys={["admin", "delete"]}
>
<ListBoxItem id="read">Read</ListBoxItem>
<ListBoxItem id="write">Write</ListBoxItem>
<ListBoxItem id="admin">Admin (restricted)</ListBoxItem>
<ListBoxItem id="delete">Delete (restricted)</ListBoxItem>
<ListBoxItem id="comment">Comment</ListBoxItem>
</ListBox>
);
};
Empty State
Use renderEmptyState to display a custom message when the list has no items.
"use client";
import { ListBox } from "@repo/ui/components/list-box";
/* EMPTY STATE */
export const Example6 = () => {
return (
<ListBox
aria-label="Search results"
selectionMode="single"
renderEmptyState={() => (
<div className="text-muted-foreground flex items-center justify-center py-6 text-sm italic">
No results found.
</div>
)}
>
{[]}
</ListBox>
);
};
Component Code
"use client";
import { Check } from "lucide-react";
import React from "react";
import {
ListBox as AriaListBox,
ListBoxItem as AriaListBoxItem,
ListBoxProps as AriaListBoxProps,
Collection,
Header,
ListBoxItemProps,
ListBoxSection,
SectionProps,
composeRenderProps,
} from "react-aria-components";
import { cn, tv } from "tailwind-variants";
import { focusRing } from "@repo/ui/lib/utils";
interface ListBoxProps<T>
extends Omit<AriaListBoxProps<T>, "layout" | "orientation"> {}
export function ListBox<T extends object>({
children,
...props
}: ListBoxProps<T>) {
return (
<AriaListBox
data-slot="list-box"
{...props}
className={cn(
props.className,
"border-border bg-popover w-[200px] rounded-lg border p-1 font-sans outline-0",
)}
>
{children}
</AriaListBox>
);
}
export const itemStyles = tv({
extend: focusRing,
base: "group relative flex cursor-default select-none items-center gap-8 rounded-md px-2.5 py-1.5 text-sm will-change-transform forced-color-adjust-none",
variants: {
isSelected: {
false: "text-popover-foreground hover:bg-accent pressed:bg-accent -outline-offset-2",
true: "bg-primary text-primary-foreground -outline-offset-4 outline-white dark:outline-white forced-colors:bg-[Highlight] forced-colors:text-[HighlightText] forced-colors:outline-[HighlightText] [&+[data-selected]]:rounded-t-none [&:has(+[data-selected])]:rounded-b-none",
},
isDisabled: {
true: "text-muted-foreground opacity-50 forced-colors:text-[GrayText]",
},
},
});
export function ListBoxItem(props: ListBoxItemProps) {
let textValue =
props.textValue ||
(typeof props.children === "string" ? props.children : undefined);
return (
<AriaListBoxItem
data-slot="list-box-item"
{...props}
textValue={textValue}
className={itemStyles}
>
{composeRenderProps(props.children, (children) => (
<>
{children}
<div className="bg-primary-foreground/20 absolute bottom-0 left-4 right-4 hidden h-px forced-colors:bg-[HighlightText] [.group[data-selected]:has(+[data-selected])_&]:block" />
</>
))}
</AriaListBoxItem>
);
}
export const dropdownItemStyles = tv({
base: "selected:pr-1 group flex cursor-default select-none items-center gap-4 rounded-lg py-2 pl-3 pr-3 text-sm no-underline outline outline-0 forced-color-adjust-none [-webkit-tap-highlight-color:transparent] [&[href]]:cursor-pointer",
variants: {
isDisabled: {
false: "text-popover-foreground",
true: "text-muted-foreground opacity-50 forced-colors:text-[GrayText]",
},
isPressed: {
true: "bg-accent",
},
isFocused: {
true: "bg-primary text-primary-foreground forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]",
},
},
compoundVariants: [
{
isFocused: false,
isOpen: true,
className: "bg-accent",
},
],
});
export function DropdownItem(props: ListBoxItemProps) {
let textValue =
props.textValue ||
(typeof props.children === "string" ? props.children : undefined);
return (
<AriaListBoxItem
data-slot="dropdown-item"
{...props}
textValue={textValue}
className={dropdownItemStyles}
>
{composeRenderProps(props.children, (children, { isSelected }) => (
<>
<span className="group-selected:font-semibold flex flex-1 items-center gap-2 truncate font-normal">
{children}
</span>
<span className="flex w-5 items-center">
{isSelected && <Check className="h-4 w-4" />}
</span>
</>
))}
</AriaListBoxItem>
);
}
export interface DropdownSectionProps<T> extends SectionProps<T> {
title?: string;
items?: any;
}
export function DropdownSection<T extends object>(
props: DropdownSectionProps<T>,
) {
return (
<ListBoxSection
data-slot="dropdown-section"
className="after:block after:h-[5px] after:content-[''] first:-mt-[5px] last:after:hidden"
>
<Header className="text-muted-foreground border-y-border bg-muted/60 supports-[-moz-appearance:none]:bg-muted sticky -top-[5px] z-10 -mx-1 -mt-px truncate border-y px-4 py-1 text-sm font-semibold backdrop-blur-md [&+*]:mt-1">
{props.title}
</Header>
<Collection items={props.items}>{props.children}</Collection>
</ListBoxSection>
);
}
Label
An accessible label component for associating text with form controls. Ensures proper reading order and focus management for improved assistive technology support.
Loader
A customizable loading spinner and progress indicator. signals wait times effectively with smooth animations and multiple size and color variants.