A searchable single-choice select with groups, descriptions, keywords, clearable value, invalid state and hidden-input form submission.
Search by name or ecosystem, like “vue”.
"use client";
import { Combobox, type ComboboxOption } from "@/components/ballmac/combobox";
import {
Field,
FieldDescription,
FieldLabel,
useFieldControl,
} from "@/components/ballmac/field";
const frameworks: ComboboxOption[] = [
{ value: "next", label: "Next.js", description: "React framework with routing", keywords: ["react"] },
{ value: "remix", label: "Remix", description: "Web standards, nested routes" },
{ value: "astro", label: "Astro", description: "Content sites with islands" },
{ value: "sveltekit", label: "SvelteKit", description: "Svelte application framework", keywords: ["svelte"] },
{ value: "nuxt", label: "Nuxt", description: "Vue framework", keywords: ["vue"] },
{ value: "solid", label: "SolidStart", description: "Fine-grained reactivity", disabled: true },
];
export default function ComboboxDemo() {
return (
<Field className="w-full max-w-sm">
<FieldLabel>Framework</FieldLabel>
<ComboboxField />
<FieldDescription>Search by name or ecosystem, like “vue”.</FieldDescription>
</Field>
);
}
function ComboboxField() {
const { id, ...control } = useFieldControl();
return (
<Combobox
id={id}
{...control}
options={frameworks}
defaultValue="next"
placeholder="Select a framework"
searchPlaceholder="Search frameworks"
clearable
/>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/comboboxInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/command @ballmac/popover @ballmac/i18nCopy the source into your project.
components/ballmac/combobox.tsx// Ballmac UI: Combobox. https://ui.ballmac.com/components/combobox "use client"; import * as React from "react"; import { ChevronsUpDown, X } from "lucide-react"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ballmac/command"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ballmac/popover"; import { cn } from "@/lib/utils"; import { useMessages } from "@/lib/ballmac/i18n"; type ComboboxOption = { /** Unique value returned in `onValueChange`. */ value: string; /** Text shown in the list and in the trigger. */ label: string; /** Muted second line in the list. */ description?: string; /** Extra words the search should match. */ keywords?: string[]; /** Leading icon or avatar. */ icon?: React.ReactNode; /** Prevent choosing this option. */ disabled?: boolean; /** Optional group heading; options with the same group are listed together. */ group?: string; }; type ComboboxProps = Omit< React.ComponentProps<"button">, "value" | "defaultValue" | "onChange" > & { /** Options to choose from. */ options: ComboboxOption[]; /** Controlled selected value ("" for none). */ value?: string; /** Initial value when uncontrolled. */ defaultValue?: string; /** Called with the new value, or "" when cleared. */ onValueChange?: (value: string) => void; /** Text on the trigger when nothing is chosen. */ placeholder?: string; /** Placeholder inside the search field. */ searchPlaceholder?: string; /** Text shown when the search has no matches. */ emptyText?: string; /** Show a control that clears the selection. */ clearable?: boolean; /** Mark the field invalid (`aria-invalid`, destructive border). */ invalid?: boolean; /** Name for a hidden input, so the value submits with a native form. */ name?: string; /** Classes for the popup panel. */ contentClassName?: string; }; /** A searchable single-choice picker: a trigger button that opens a filterable list. */ function Combobox({ options, value: valueProp, defaultValue = "", onValueChange, placeholder, searchPlaceholder, emptyText, clearable = false, invalid = false, disabled, name, className, contentClassName, "aria-label": ariaLabel, ...props }: ComboboxProps) { const msg = useMessages() placeholder ??= msg("combobox.placeholder", "Select an option") searchPlaceholder ??= msg("combobox.searchPlaceholder", "Search") emptyText ??= msg("combobox.emptyText", "No results found.") const [open, setOpen] = React.useState(false); const [inner, setInner] = React.useState(defaultValue); const value = valueProp ?? inner; const selected = options.find((o) => o.value === value); const choose = (next: string) => { if (valueProp === undefined) setInner(next); onValueChange?.(next); }; const groups = React.useMemo(() => { const map = new Map<string, ComboboxOption[]>(); for (const o of options) { const key = o.group ?? ""; map.set(key, [...(map.get(key) ?? []), o]); } return [...map.entries()]; }, [options]); return ( <Popover open={open} onOpenChange={setOpen}> <div className="relative w-full"> <PopoverTrigger data-slot="combobox" role="combobox" aria-expanded={open} aria-invalid={invalid || undefined} aria-label={ariaLabel} disabled={disabled} className={cn( "flex h-9 w-full min-w-0 items-center justify-between gap-2 rounded-md border border-input bg-background px-3 text-sm shadow-xs outline-none transition-[color,border-color,box-shadow] duration-150 hover:bg-accent/40 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30", clearable && selected && "pe-14", !selected && "text-muted-foreground", className, )} {...props} > <span className="flex min-w-0 items-center gap-2"> {selected?.icon} <span className="truncate">{selected ? selected.label : placeholder}</span> </span> <ChevronsUpDown aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" /> </PopoverTrigger> {clearable && selected && !disabled && ( <button type="button" aria-label={msg("combobox.clearSelection", "Clear selection")} onClick={() => choose("")} className="absolute top-1/2 end-8 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded text-muted-foreground outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50" > <X aria-hidden="true" className="size-3.5" /> </button> )} {name && <input type="hidden" name={name} value={value} />} </div> <PopoverContent label={ariaLabel ?? placeholder} align="start" sideOffset={4} className={cn( "w-(--radix-popover-trigger-width) min-w-56 gap-0 p-0", contentClassName, )} > <Command label={ariaLabel ?? placeholder}> <CommandInput placeholder={searchPlaceholder} /> <CommandList> <CommandEmpty>{emptyText}</CommandEmpty> {groups.map(([heading, list]) => ( <CommandGroup key={heading || "options"} heading={heading || undefined}> {list.map((o) => ( <CommandItem key={o.value} value={o.value} keywords={[o.label, ...(o.keywords ?? [])]} icon={o.icon} description={o.description} selected={o.value === value} disabled={o.disabled} onSelect={() => { choose(o.value === value && clearable ? "" : o.value); setOpen(false); }} > {o.label} </CommandItem> ))} </CommandGroup> ))} </CommandList> </Command> </PopoverContent> </Popover> ); } export { Combobox, type ComboboxProps, type ComboboxOption };Update the import paths to match your project setup.
Usage
import { Combobox } from "@/components/ballmac/combobox"The full example is in the Code tab above.
Examples
Groups and states
"use client";
import { Globe } from "lucide-react";
import { Combobox, type ComboboxOption } from "@/components/ballmac/combobox";
const zones: ComboboxOption[] = [
{ value: "utc", label: "UTC", group: "Universal", icon: <Globe aria-hidden="true" className="size-4" /> },
{ value: "nyc", label: "New York", description: "GMT−5", group: "Americas", keywords: ["eastern"] },
{ value: "sao", label: "São Paulo", description: "GMT−3", group: "Americas" },
{ value: "ldn", label: "London", description: "GMT+0", group: "Europe" },
{ value: "ber", label: "Berlin", description: "GMT+1", group: "Europe" },
{ value: "tok", label: "Tokyo", description: "GMT+9", group: "Asia" },
];
export default function ComboboxStates() {
return (
<div className="grid w-full max-w-sm gap-3">
<Combobox aria-label="Time zone" options={zones} defaultValue="ldn" placeholder="Pick a time zone" />
<Combobox aria-label="Required time zone" options={zones} invalid placeholder="Pick a time zone (required)" />
<Combobox aria-label="Locked time zone" options={zones} defaultValue="utc" disabled />
</div>
);
}API reference
| Prop | Type | Default |
|---|---|---|
options*Options to choose from. | ComboboxOption[] | — |
valueControlled selected value ("" for none). | string | — |
defaultValueInitial value when uncontrolled. | string | "" |
onValueChangeCalled with the new value, or "" when cleared. | (value: string) => void | — |
placeholderText on the trigger when nothing is chosen. | string | — |
searchPlaceholderPlaceholder inside the search field. | string | — |
emptyTextText shown when the search has no matches. | string | — |
clearableShow a control that clears the selection. | boolean | false |
invalidMark the field invalid (`aria-invalid`, destructive border). | boolean | false |
nameName for a hidden input, so the value submits with a native form. | string | — |
contentClassNameClasses for the popup panel. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter / Space / ArrowDown | Opens the list from the trigger |
| Type | Filters options; matches value, label and keywords |
| ArrowUp / ArrowDown / Enter | Moves and selects |
| Escape | Closes and returns focus to the trigger |
Use with AI
Pass options with value and label. It opens a filterable list; the trigger is a combobox button that submits through name. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Combobox (@ballmac/combobox) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Choosing from more than about seven options
- Time zones, countries, users and frameworks
Not for
- Short lists; use select or radio-group
- Choosing several values; use multi-select
Registry JSON: https://ui.ballmac.com/r/combobox.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
Pairs well with
Field
Form field layout with label, description and error that wire their ids to the control automatically, plus fieldset, legend, orientation and invalid/disabled state.
Command
A filterable command list on cmdk, inline or in a Cmd/Ctrl+K dialog, with groups, shortcuts, descriptions, loading state and page-safe scrolling.
Popover
A focus-managed compact surface for controls or details, with collision handling, mobile-safe width, and optional close action.
Calendar
A date grid for one day, several days or a range, with month and year selects, week numbers, disabled rules and range-end styling, on React DayPicker.