All components
Listbox
An always-visible option list with selection indicators in subtle and solid panels.
Notifications
All activity
Mentions only
Weekly digest
Muted
123456789101112131415161718192021import { Listbox } from "#components/ui/base-ui/listbox.tsx";
const options = ["All activity", "Mentions only", "Weekly digest", "Muted"];
export function Example() { return ( // Base UI has no listbox machine, so Root owns the selection state // (value/defaultValue/onValueChange); parts compose through render <Listbox.Root defaultValue="Weekly digest" variant="subtle"> <Listbox.Label>Notifications</Listbox.Label> <Listbox.Content aria-label="Notifications"> {options.map((option) => ( <Listbox.Item key={option} value={option}> <Listbox.ItemText>{option}</Listbox.ItemText> <Listbox.ItemIndicator /> </Listbox.Item> ))} </Listbox.Content> </Listbox.Root> );}Variants
Notifications
subtleAll activity
Mentions only
Weekly digest
Muted
Notifications
solidAll activity
Mentions only
Weekly digest
Muted
Install
1npx stylexblocks add listboxSource
components/ui/base-ui/listbox.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348import * as stylex from "@stylexjs/stylex";import { createContext, useContext, useMemo, useState } from "react";import { mergeProps } from "@base-ui-components/react/merge-props";import { useRender } from "@base-ui-components/react/use-render";import { createSlotRecipeContext } from "#lib/base-ui/create-slot-recipe-context.tsx";import { defineSlotRecipe, resolveSlotStyles, type SlotRecipeVariants,} from "#lib/recipe-props.ts";import { listboxItemIndicatorIconVars } from '@stylexblocks/ui/slot-recipes/listbox.stylex.ts'
import { cursor } from '@stylexblocks/ui/tokens/cursor.stylex.ts'import { fontSizes } from '@stylexblocks/ui/tokens/font-sizes.stylex.ts'import { fontWeights } from '@stylexblocks/ui/tokens/font-weights.stylex.ts'import { lineHeights } from '@stylexblocks/ui/tokens/line-heights.stylex.ts'import { semanticColors } from '@stylexblocks/ui/semantic-tokens/colors.stylex.ts'import { semanticRadii } from '@stylexblocks/ui/semantic-tokens/radii.stylex.ts'import { sizes } from '@stylexblocks/ui/tokens/sizes.stylex.ts'import { spacing } from '@stylexblocks/ui/tokens/spacing.stylex.ts'
const listboxRoot = stylex.create({ base: { display: 'flex', flexDirection: 'column', gap: spacing._1_5, width: sizes.full, },})
const listboxContent = stylex.create({ base: { display: 'flex', flexDirection: 'column', overflowY: 'auto', maxHeight: sizes._96, padding: spacing._1, gap: spacing._1, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, outline: 'none', scrollPadding: 1, '--listbox-item-padding-x': spacing._2, '--listbox-item-padding-y': spacing._1_5, },})
const listboxContentVariants = stylex.create({ subtle: { backgroundColor: semanticColors.bgPanel, borderWidth: '1px', borderRadius: semanticRadii.panelMd, }, solid: { backgroundColor: semanticColors.bgPanel, borderWidth: '1px', borderRadius: semanticRadii.panelMd, },})
const listboxItem = stylex.create({ base: { position: 'relative', userSelect: 'none', display: 'flex', alignItems: 'center', gap: spacing._2, cursor: cursor.option, justifyContent: 'space-between', flex: 1, textAlign: 'start', borderRadius: semanticRadii.controlMd, paddingBlock: 'var(--listbox-item-padding-y)', paddingInline: 'var(--listbox-item-padding-x)', pointerEvents: { default: null, ':is(:disabled, [disabled], [data-disabled])': 'none', }, opacity: { default: null, ':is(:disabled, [disabled], [data-disabled])': 0.5, }, },})
const listboxItemVariants = stylex.create({ subtle: { backgroundColor: { default: null, ':hover': `color-mix(in oklch, ${semanticColors.bgEmphasized} 60%, transparent)`, }, }, solid: {},})
const listboxEmpty = stylex.create({ base: { paddingBlock: 'var(--listbox-item-padding-y)', paddingInline: 'var(--listbox-item-padding-x)', },})
const listboxItemText = stylex.create({ base: { flex: 1, },})
const listboxItemGroup = stylex.create({ base: { marginTop: { default: spacing._1_5, ':first-child': 0, }, },})
const listboxItemGroupLabel = stylex.create({ base: { paddingBlock: spacing._1_5, paddingInline: spacing._2, fontWeight: fontWeights.medium, },})
const listboxLabel = stylex.create({ base: { fontWeight: fontWeights.medium, userSelect: 'none', fontSize: fontSizes.sm, lineHeight: lineHeights.sm, opacity: { default: null, ':is(:disabled, [disabled], [data-disabled])': 0.5, }, cursor: { default: null, ':is(:disabled, [disabled], [data-disabled])': 'not-allowed', }, },})
const listboxValueText = stylex.create({ base: { lineClamp: 1, maxWidth: '80%', },})
const listboxItemIndicator = stylex.create({ base: { display: 'flex', alignItems: 'center', justifyContent: 'center', [listboxItemIndicatorIconVars.width]: sizes._4, [listboxItemIndicatorIconVars.height]: sizes._4, },})
const listboxItemIndicatorIcon = stylex.create({ base: { width: listboxItemIndicatorIconVars.width, height: listboxItemIndicatorIconVars.height, },})
export type ListboxVariant = keyof typeof listboxContentVariants
const listboxRecipe = defineSlotRecipe({ slots: { root: { styles: listboxRoot }, content: { styles: listboxContent, variants: listboxContentVariants }, item: { styles: listboxItem, variants: listboxItemVariants }, empty: { styles: listboxEmpty }, itemText: { styles: listboxItemText }, itemGroup: { styles: listboxItemGroup }, itemGroupLabel: { styles: listboxItemGroupLabel }, label: { styles: listboxLabel }, valueText: { styles: listboxValueText }, itemIndicator: { styles: listboxItemIndicator }, }, defaultVariants: { variant: "subtle" },});
const chrome = stylex.create({ itemHover: { ":hover": { backgroundColor: semanticColors.bgMuted } }, itemSelected: { backgroundColor: { default: null, ':is([aria-selected="true"])': semanticColors.bgMuted, }, },});
function CheckIcon(props: React.ComponentProps<"svg">) { return ( <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}> <path d="m3.5 8.5 3 3 6-7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> </svg> );}
interface ListboxContextValue { value: string | null; select: (value: string) => void;}
const ListboxContext = createContext<ListboxContextValue>({ value: null, select: () => {},});
const ItemContext = createContext<{ value: string; selected: boolean }>({ value: "", selected: false,});
const { withProvider, withContext } = createSlotRecipeContext(listboxRecipe);
export type ListboxVariantProps = Partial<SlotRecipeVariants<typeof listboxRecipe>>;
function RootNode({ render, value, defaultValue = null, onValueChange, ...rest}: useRender.ComponentProps<"div"> & { value?: string | null; defaultValue?: string | null; onValueChange?: (value: string) => void;}) { const [inner, setInner] = useState<string | null>(defaultValue); const current = value !== undefined ? value : inner; const ctx = useMemo<ListboxContextValue>( () => ({ value: current, select: (next: string) => { setInner(next); onValueChange?.(next); }, }), [current, onValueChange], ); const element = useRender({ defaultTagName: "div", render: render as useRender.RenderProp, props: rest, }); return <ListboxContext.Provider value={ctx}>{element}</ListboxContext.Provider>;}
function ContentNode({ render, ...rest }: useRender.ComponentProps<"div">) { return useRender({ defaultTagName: "div", render: render as useRender.RenderProp, props: mergeProps({ role: "listbox" }, rest), });}
function ItemNode({ render, value, disabled, onClick, onKeyDown, ...rest}: useRender.ComponentProps<"div"> & { value: string; disabled?: boolean }) { const ctx = useContext(ListboxContext); const selected = ctx.value === value; const itemCtx = useMemo(() => ({ value, selected }), [value, selected]); const element = useRender({ defaultTagName: "div", render: render as useRender.RenderProp, props: mergeProps( { role: "option" as const, "aria-selected": selected, "aria-disabled": disabled || undefined, tabIndex: disabled ? undefined : 0, onClick: (event: React.MouseEvent<HTMLDivElement>) => { (onClick as ((event: React.MouseEvent<HTMLDivElement>) => void) | undefined)?.(event); if (!disabled) ctx.select(value); }, onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => { (onKeyDown as ((event: React.KeyboardEvent<HTMLDivElement>) => void) | undefined)?.( event, ); if (!disabled && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); ctx.select(value); } }, }, rest, ), }); return <ItemContext.Provider value={itemCtx}>{element}</ItemContext.Provider>;}
function ItemIndicatorNode({ children, ...props }: React.ComponentProps<"span">) { const { selected } = useContext(ItemContext); if (!selected) return null; return ( <span {...props}> {children ?? <CheckIcon {...stylex.props(listboxItemIndicatorIcon.base)} />} </span> );}
function ItemGroupNode({ render, ...rest }: useRender.ComponentProps<"div">) { return useRender({ defaultTagName: "div", render: render as useRender.RenderProp, props: mergeProps({ role: "group" }, rest), });}
/** * Listbox — an always-visible option list. Base UI ships no listbox machine, * so Root owns the selection state (controlled via `value`/`onValueChange` * or uncontrolled via `defaultValue`). `<Listbox.Root variant>` resolves the * recipe once; every part inherits it through context. Composition per part * via `render`. */export const Listbox = { Root: withProvider(RootNode, "root", "ListboxRoot"), Label: withContext("span", "label", "ListboxLabel"), Content: withContext(ContentNode, "content", "ListboxContent"), Item: withContext( ItemNode, (v) => [ ...resolveSlotStyles(listboxRecipe.slots.item, v), chrome.itemHover, chrome.itemSelected, ], "ListboxItem", ), ItemText: withContext("span", "itemText", "ListboxItemText"), ItemIndicator: withContext(ItemIndicatorNode, "itemIndicator", "ListboxItemIndicator"), ItemGroup: withContext(ItemGroupNode, "itemGroup", "ListboxItemGroup"), ItemGroupLabel: withContext("div", "itemGroupLabel", "ListboxItemGroupLabel"),};
export type ListboxRootProps = React.ComponentProps<typeof Listbox.Root>;export type ListboxItemProps = React.ComponentProps<typeof Listbox.Item>;