Combobox
A searchable select on Popover with groups, highlighted matches, an empty state and a multi-select chips variant, following the ARIA 1.2 combobox pattern.
Preview
Assignee
Type a name or a team.
Time zone
Pick a time zone to schedule reports.
Labels
BugPerformanceAccessibility
Backspace removes the last label.Install
Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.
npx shadcn@latest add https://pebbleui.com/r/combobox.jsonProps
| Prop | Type | Default | Description |
|---|---|---|---|
| options | ComboboxOption[] | — | { value, label, description?, meta?, icon?, group?, keywords?, disabled? }. Groups keep first-seen order. |
| multiple | boolean | false | Select many: chips in the field, the list stays open and toggles. |
| value / defaultValue | string | null · string[] | — | Controlled or initial selection; an array when multiple. |
| onValueChange | (value) => void | — | Called with the new value (string | null, or string[] when multiple). |
| placeholder | string | "Search…" | Shown while the field is empty. |
| emptyMessage | (query: string) => ReactNode | — | Replaces the default “No match for …” text. |
| filter | (option, query) => boolean | accent-insensitive substring | Custom matcher. The default searches label, description and keywords. |
| leading | ReactNode | — | Field icon. A selected single option shows its own icon instead. |
| invalid | boolean | false | Danger edge and halo; sets aria-invalid. Pair with an error via aria-describedby. |
| disabled | boolean | false | Dims the field and blocks opening. |
| name | string | — | Renders a hidden input per selected value for native forms. |
| open / defaultOpen / onOpenChange | boolean · (open) => void | — | Control the popover. |
Build notes
- Radix Popover with `PopoverAnchor asChild` around the field; content uses
`w-(--radix-popover-trigger-width)` (Radix measures the anchor).
`onOpenAutoFocus`/`onCloseAutoFocus` are prevented so focus stays in the
input, and `onInteractOutside` ignores pointer-downs inside the anchor so
clicking the field does not close-then-reopen.
- Options use `onMouseDown={preventDefault}` so clicking never blurs the
input; `onMouseMove` sets the active option (not mouseenter, so a list
scrolling under a still pointer does not steal the highlight).
- `query` is `null` while not typing; a single field then displays the
selected label. Blur resets `query` to `null`.
- The active value is derived: the stored one if it is still visible and
enabled, else the first selected visible option, else the first enabled.
An effect scrolls it into view (`block: "nearest"`).
- Match highlight folds case and diacritics (`NFD` + strip `\p{Diacritic}`)
for filtering; the `<mark>` is only drawn when folding kept the label's
length, so indices map 1:1.
- The gliding highlight is a `motion.span` with a per-instance
`layoutId` rendered only inside the active row; the check uses
`starting:scale-50 starting:opacity-0` for a CSS-only pop on mount.
<!-- PEBBLE-LICENSE -->View source
registry/input/combobox/combobox.tsx
"use client";
/* PEBBLE-LICENSE
Built by Pebble. Component: combobox.
First-party license: MIT
The following first-party terms apply only to original Pebble material.
MIT License
Copyright (c) 2026 Praxys Solutions LLC (Pebble)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */
import * as React from "react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import { cn } from "@/lib/utils";
import { Popover, PopoverAnchor, PopoverContent } from "@/registry/overlay/popover/popover";
export type ComboboxOption = {
value: string;
label: string;
/** Second line under the label. */
description?: string;
/** Right-aligned meta (an offset, a count, a shortcut). */
meta?: string;
/** Leading 16 px visual: an icon, avatar or dot. */
icon?: React.ReactNode;
/** Heading the option is listed under; groups keep first-seen order. */
group?: string;
/** Extra search terms that match but are not shown. */
keywords?: string[];
disabled?: boolean;
};
type ComboboxBaseProps = {
options: ComboboxOption[];
placeholder?: string;
/** Shown when nothing matches. Receives the query. */
emptyMessage?: (query: string) => React.ReactNode;
disabled?: boolean;
/** Marks the field invalid (danger ring, `aria-invalid`). */
invalid?: boolean;
/** Leading icon inside the field. */
leading?: React.ReactNode;
/** Custom matcher; defaults to a case- and accent-insensitive substring test. */
filter?: (option: ComboboxOption, query: string) => boolean;
/** Form field name; a hidden input per value is rendered. */
name?: string;
id?: string;
className?: string;
"aria-label"?: string;
"aria-labelledby"?: string;
"aria-describedby"?: string;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
};
export type ComboboxSingleProps = ComboboxBaseProps & {
multiple?: false;
value?: string | null;
defaultValue?: string | null;
onValueChange?: (value: string | null) => void;
};
export type ComboboxMultipleProps = ComboboxBaseProps & {
multiple: true;
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
};
export type ComboboxProps = ComboboxSingleProps | ComboboxMultipleProps;
const SPRING = { type: "spring", stiffness: 420, damping: 32, mass: 0.8 } as const;
function fold(text: string) {
return text
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase();
}
export function defaultFilter(option: ComboboxOption, query: string) {
const q = fold(query.trim());
if (!q) return true;
return [option.label, option.description ?? "", ...(option.keywords ?? [])].some((t) =>
fold(t).includes(q),
);
}
/** Wraps the first match of `query` in `label` with a <mark>. */
function Highlight({ label, query }: { label: string; query: string }) {
const q = fold(query.trim());
// Folding can change length (é → e + mark), so only highlight when the
// folded label keeps a 1:1 mapping with the original.
const folded = fold(label);
const at = q && folded.length === label.length ? folded.indexOf(q) : -1;
if (at < 0) return <>{label}</>;
return (
<>
{label.slice(0, at)}
<mark
data-slot="combobox-match"
className="rounded-[3px] bg-accent/18 text-foreground underline decoration-accent/70 decoration-1 underline-offset-[3px]"
>
{label.slice(at, at + q.length)}
</mark>
{label.slice(at + q.length)}
</>
);
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className={cn("size-4", className)}
>
<path d="M3.5 8.5 6.5 11.5l6-7" />
</svg>
);
}
function ChevronIcon() {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="size-4"
>
<path d="M5 6.5 8 9.5l3-3" />
</svg>
);
}
function XIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
aria-hidden
className={cn("size-3", className)}
>
<path d="m4.5 4.5 7 7m0-7-7 7" />
</svg>
);
}
function SearchIcon() {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
aria-hidden
className="size-4"
>
<circle cx="7.25" cy="7.25" r="4.25" />
<path d="m10.5 10.5 3 3" />
</svg>
);
}
export function Combobox(props: ComboboxProps) {
const {
options,
placeholder = "Search…",
emptyMessage,
disabled = false,
invalid = false,
leading,
filter = defaultFilter,
name,
id,
className,
open: openProp,
defaultOpen = false,
onOpenChange,
} = props;
const multiple = props.multiple === true;
const autoId = React.useId();
const inputId = id ?? `${autoId}-input`;
const listId = `${autoId}-list`;
const optionId = (value: string) => `${autoId}-opt-${value.replace(/[^\w-]/g, "_")}`;
const anchorRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
/* ----- value ----- */
const controlled = Object.prototype.hasOwnProperty.call(props, "value");
const [internal, setInternal] = React.useState<string[]>(() => {
const d = props.defaultValue;
return Array.isArray(d) ? d : d ? [d] : [];
});
const values: string[] = controlled
? Array.isArray(props.value)
? props.value
: props.value
? [props.value]
: []
: internal;
const selectedOptions = values
.map((v) => options.find((o) => o.value === v))
.filter((o): o is ComboboxOption => !!o);
const commit = (next: string[]) => {
setInternal(next);
if (multiple) (props.onValueChange as ComboboxMultipleProps["onValueChange"])?.(next);
else (props.onValueChange as ComboboxSingleProps["onValueChange"])?.(next[0] ?? null);
};
/* ----- open ----- */
const [openState, setOpenState] = React.useState(defaultOpen);
const open = (openProp ?? openState) && !disabled;
const setOpen = (next: boolean) => {
if (next === open) return;
setOpenState(next);
onOpenChange?.(next);
};
/* ----- query ----- */
// `null` means "not typing": a single combobox then shows its value's label.
const [query, setQuery] = React.useState<string | null>(null);
const search = query ?? "";
const singleLabel = !multiple ? (selectedOptions[0]?.label ?? "") : "";
const inputValue = multiple ? search : (query ?? singleLabel);
const filtered = options.filter((o) => filter(o, search));
const groups: { name: string | undefined; items: ComboboxOption[] }[] = [];
for (const o of filtered) {
const g = groups.find((x) => x.name === o.group);
if (g) g.items.push(o);
else groups.push({ name: o.group, items: [o] });
}
const ordered = groups.flatMap((g) => g.items);
const enabled = ordered.filter((o) => !o.disabled);
/* ----- highlight (aria-activedescendant) ----- */
const [active, setActive] = React.useState<string | null>(null);
const activeValue =
active && enabled.some((o) => o.value === active)
? active
: (enabled.find((o) => values.includes(o.value))?.value ?? enabled[0]?.value ?? null);
React.useEffect(() => {
if (!open || !activeValue) return;
const el = listRef.current?.querySelector<HTMLElement>(
`[data-value="${CSS.escape(activeValue)}"]`,
);
el?.scrollIntoView?.({ block: "nearest" });
}, [open, activeValue]);
const moveActive = (step: number) => {
if (!enabled.length) return;
const i = enabled.findIndex((o) => o.value === activeValue);
const next = enabled[(i + step + enabled.length) % enabled.length];
setActive(next.value);
};
const choose = (option: ComboboxOption) => {
if (option.disabled) return;
if (multiple) {
commit(
values.includes(option.value)
? values.filter((v) => v !== option.value)
: [...values, option.value],
);
setQuery(null);
setActive(option.value);
} else {
commit([option.value]);
setQuery(null);
setOpen(false);
}
};
const remove = (value: string) => {
commit(values.filter((v) => v !== value));
inputRef.current?.focus();
};
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
if (!open) setOpen(true);
else moveActive(1);
break;
case "ArrowUp":
event.preventDefault();
if (!open) setOpen(true);
else moveActive(-1);
break;
case "Enter": {
if (!open) return;
const option = enabled.find((o) => o.value === activeValue);
if (option) {
event.preventDefault();
choose(option);
}
break;
}
case "Escape":
if (open) {
event.preventDefault();
setOpen(false);
setQuery(null);
} else if (!multiple && values.length) {
// APG: Escape on a closed combobox clears it.
event.preventDefault();
commit([]);
setQuery(null);
}
break;
case "Backspace":
if (multiple && !search && values.length) {
commit(values.slice(0, -1));
}
break;
case "Tab":
setOpen(false);
setQuery(null);
break;
}
};
const describedBy = props["aria-describedby"];
// A chosen single option shows its own visual in the field until typing.
const fieldIcon = (!multiple && query === null && selectedOptions[0]?.icon) || leading;
return (
<MotionConfig reducedMotion="user">
<Popover open={open} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<div
ref={anchorRef}
data-slot="combobox"
data-state={open ? "open" : "closed"}
data-disabled={disabled || undefined}
onMouseDown={(event) => {
// Clicks on the field's padding focus the input and toggle.
if (disabled || event.target === inputRef.current) return;
if ((event.target as HTMLElement).closest("button")) return;
event.preventDefault();
inputRef.current?.focus();
setOpen(!open);
}}
className={cn(
"group/combobox relative flex min-h-9 w-full cursor-text flex-wrap items-center gap-1 rounded-md border border-border-control bg-card py-1 ps-1.5 pe-9 text-sm inset-shadow-xs",
"transition-[border-color,box-shadow,background-color] duration-(--motion-fast) ease-enter motion-reduce:transition-none",
"hover:border-muted-foreground",
"has-[input:focus-visible]:border-muted-foreground has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-ring has-[input:focus-visible]:ring-offset-2 has-[input:focus-visible]:ring-offset-background",
"data-[state=open]:border-muted-foreground",
invalid &&
"border-danger ring-[3px] ring-danger/20 hover:border-danger has-[input:focus-visible]:ring-danger/40 data-[state=open]:border-danger",
disabled &&
"pointer-events-none cursor-not-allowed bg-muted opacity-50 inset-shadow-none",
className,
)}
>
{fieldIcon && (
<span
aria-hidden
className="flex size-6 shrink-0 items-center justify-center text-muted-foreground transition-colors group-focus-within/combobox:text-foreground [&_svg]:size-4"
>
{fieldIcon}
</span>
)}
{multiple && (
<AnimatePresence initial={false} mode="popLayout">
{selectedOptions.map((o) => (
<motion.span
key={o.value}
layout
data-slot="combobox-chip"
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85 }}
transition={SPRING}
className="inline-flex h-6 max-w-40 items-center gap-1.5 rounded-full bg-card ps-2 pe-0.5 text-xs font-medium text-foreground shadow-raised-xs ring-1 inset-shadow-highlight ring-hairline [&_svg]:size-3"
>
{o.icon && (
<span aria-hidden className="flex shrink-0 items-center">
{o.icon}
</span>
)}
<span className="truncate">{o.label}</span>
<button
type="button"
tabIndex={-1}
aria-label={`Remove ${o.label}`}
onClick={() => remove(o.value)}
className="grid size-5 cursor-pointer place-items-center rounded-full text-muted-foreground transition-colors duration-(--motion-fast) hover:bg-muted hover:text-foreground"
>
<XIcon />
</button>
</motion.span>
))}
</AnimatePresence>
)}
<input
ref={inputRef}
id={inputId}
type="text"
role="combobox"
autoComplete="off"
spellCheck={false}
aria-autocomplete="list"
aria-expanded={open}
aria-controls={listId}
aria-haspopup="listbox"
aria-activedescendant={open && activeValue ? optionId(activeValue) : undefined}
aria-invalid={invalid || undefined}
aria-label={props["aria-label"]}
aria-labelledby={props["aria-labelledby"]}
aria-describedby={describedBy}
disabled={disabled}
placeholder={multiple && values.length ? "" : placeholder}
value={inputValue}
onChange={(e) => {
setQuery(e.target.value);
setActive(null);
if (!open) setOpen(true);
}}
onClick={() => setOpen(true)}
onKeyDown={onKeyDown}
onBlur={() => setQuery(null)}
className={cn(
"h-7 min-w-16 flex-1 bg-transparent px-1.5 text-base text-foreground outline-none placeholder:text-muted-foreground md:text-sm",
"selection:bg-primary selection:text-primary-foreground",
)}
/>
<button
type="button"
tabIndex={-1}
aria-label={open ? "Hide options" : "Show options"}
disabled={disabled}
onClick={() => {
inputRef.current?.focus();
setOpen(!open);
}}
className="absolute inset-y-0 end-0 grid w-9 cursor-pointer place-items-center text-muted-foreground transition-[color,rotate] duration-(--motion-base) ease-enter group-data-[state=open]/combobox:rotate-180 group-data-[state=open]/combobox:text-foreground hover:text-foreground motion-reduce:transition-none"
>
<ChevronIcon />
</button>
{name && values.map((v) => <input key={v} type="hidden" name={name} value={v} />)}
</div>
</PopoverAnchor>
<PopoverContent
align="start"
sideOffset={6}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onInteractOutside={(e) => {
if (anchorRef.current?.contains(e.target as Node)) e.preventDefault();
}}
className="w-(--radix-popover-trigger-width) min-w-56 p-1"
>
<div
ref={listRef}
id={listId}
role="listbox"
aria-label={props["aria-label"]}
aria-labelledby={props["aria-labelledby"]}
aria-multiselectable={multiple || undefined}
className="max-h-72 scroll-py-1 overflow-y-auto overscroll-contain"
>
{ordered.length === 0 ? (
<div
data-slot="combobox-empty"
role="presentation"
className="flex flex-col items-center gap-1.5 px-3 py-6 text-center"
>
<span className="grid size-8 place-items-center rounded-full bg-muted text-muted-foreground inset-shadow-xs">
<SearchIcon />
</span>
<span className="text-[13px] text-muted-foreground">
{emptyMessage ? (
emptyMessage(search)
) : (
<>
No match for <span className="font-medium text-foreground">“{search}”</span>
</>
)}
</span>
</div>
) : (
groups.map((group, gi) => {
const headingId = `${autoId}-g${gi}`;
const items = group.items.map((o) => {
const isSelected = values.includes(o.value);
const isActive = o.value === activeValue;
return (
<div
key={o.value}
id={optionId(o.value)}
role="option"
data-slot="combobox-option"
data-value={o.value}
data-active={isActive || undefined}
aria-selected={isSelected}
aria-disabled={o.disabled || undefined}
onMouseDown={(e) => e.preventDefault()}
onMouseMove={() => !o.disabled && !isActive && setActive(o.value)}
onClick={() => choose(o)}
className={cn(
"relative flex min-h-8 cursor-pointer items-center gap-2.5 rounded-md py-1.5 ps-2 pe-8 text-sm text-foreground select-none",
"[&_svg:not([class*='size-'])]:size-4",
o.disabled && "cursor-not-allowed opacity-45",
)}
>
{isActive && (
// One highlight that glides between rows.
<motion.span
layoutId={`${autoId}-highlight`}
aria-hidden
transition={SPRING}
className="absolute inset-0 rounded-md bg-muted forced-colors:outline-2 forced-colors:outline-[Highlight]"
/>
)}
{o.icon && (
<span
aria-hidden
className="relative flex shrink-0 items-center text-muted-foreground"
>
{o.icon}
</span>
)}
<span className="relative flex min-w-0 flex-1 flex-col">
<span className={cn("truncate", isSelected && "font-medium")}>
<Highlight label={o.label} query={search} />
</span>
{o.description && (
<span className="truncate text-xs text-muted-foreground">
{o.description}
</span>
)}
</span>
{o.meta && (
<span className="relative shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums">
{o.meta}
</span>
)}
<span className="absolute end-2 grid size-4 place-items-center text-accent-strong">
{isSelected && (
<CheckIcon className="transition-[scale,opacity] duration-(--motion-base) ease-enter motion-reduce:transition-none starting:scale-50 starting:opacity-0" />
)}
</span>
</div>
);
});
return group.name ? (
<div
key={group.name}
role="group"
aria-labelledby={headingId}
className={cn(gi > 0 && "mt-1 border-t border-border pt-1")}
>
<div
id={headingId}
role="presentation"
className="px-2 pt-1.5 pb-1 text-[11px] font-medium tracking-wide text-muted-foreground uppercase"
>
{group.name}
</div>
{items}
</div>
) : (
<React.Fragment key={`ungrouped-${gi}`}>{items}</React.Fragment>
);
})
)}
</div>
{multiple && values.length > 0 && (
<div className="mt-1 flex items-center justify-between border-t border-border px-2 pt-1.5 pb-0.5 text-xs text-muted-foreground">
<span className="tabular-nums">{values.length} selected</span>
<button
type="button"
tabIndex={-1}
onMouseDown={(e) => e.preventDefault()}
onClick={() => commit([])}
className="cursor-pointer rounded px-1 font-medium text-foreground hover:bg-muted"
>
Clear
</button>
</div>
)}
</PopoverContent>
</Popover>
</MotionConfig>
);
}
