Combobox
Searchable combobox with a morphing portal, grouped filtering, keyboard navigation, and controlled or uncontrolled state.
Preview
Workspace
"use client";
import { Blocks, Box, Component, Layers3 } from "lucide-react";
import { useState } from "react";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxSeparator,
ComboboxTrigger,
} from "@/components/motion/combobox";
const WORKSPACES = [
{
value: "studio",
label: "Design studio",
detail: "12 projects",
group: "Recent",
icon: Component,
color: "bg-amber-400/20 text-amber-700 dark:text-amber-300",
},
{
value: "product",
label: "Product team",
detail: "8 projects",
group: "Recent",
icon: Layers3,
color: "bg-sky-400/20 text-sky-700 dark:text-sky-300",
},
{
value: "playground",
label: "Playground",
detail: "24 experiments",
group: "Workspaces",
icon: Blocks,
color: "bg-emerald-400/20 text-emerald-700 dark:text-emerald-300",
},
{
value: "archive",
label: "Component archive",
detail: "41 components",
group: "Workspaces",
icon: Box,
color: "bg-rose-400/20 text-rose-700 dark:text-rose-300",
},
] as const;
function WorkspaceMark({ value }: { value: string }) {
const workspace = WORKSPACES.find((item) => item.value === value);
if (!workspace) return null;
const Icon = workspace.icon;
return (
<span
className={`grid size-7 shrink-0 place-items-center rounded-lg ${workspace.color}`}
>
<Icon className="size-3.5" />
</span>
);
}
export function ComboboxPreview() {
const [value, setValue] = useState("studio");
return (
<div className="w-full max-w-72">
<p className="mb-2 text-xs font-medium text-muted-foreground">
Workspace
</p>
<Combobox value={value} onValueChange={setValue}>
<ComboboxTrigger className="h-12 rounded-2xl px-2.5">
<ComboboxInput
aria-label="Search workspaces"
placeholder="Search workspaces…"
/>
</ComboboxTrigger>
<ComboboxContent className="w-72 rounded-2xl">
<ComboboxList ariaLabel="Workspaces" className="p-2">
<ComboboxEmpty>No workspaces found.</ComboboxEmpty>
{(["Recent", "Workspaces"] as const).map((group, groupIndex) => (
<ComboboxGroup key={group}>
{groupIndex > 0 ? <ComboboxSeparator /> : null}
<ComboboxLabel>{group}</ComboboxLabel>
{WORKSPACES.filter((item) => item.group === group).map(
(workspace) => (
<ComboboxItem
key={workspace.value}
value={workspace.value}
textValue={workspace.label}
keywords={[workspace.detail, workspace.group]}
className="py-2"
>
<span className="flex min-w-0 items-center gap-2.5">
<WorkspaceMark value={workspace.value} />
<span className="min-w-0">
<span className="block truncate font-medium text-foreground">
{workspace.label}
</span>
<span className="block truncate text-xs text-muted-foreground">
{workspace.detail}
</span>
</span>
</span>
</ComboboxItem>
),
)}
</ComboboxGroup>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
);
}
"use client";
// beui.dev/components/motion/combobox
export {
ComboboxContent,
type ComboboxContentProps,
} from "./combobox/content";
export {
Combobox,
type ComboboxFilter,
type ComboboxProps,
} from "./combobox/context";
export {
ComboboxEmpty,
type ComboboxEmptyProps,
ComboboxGroup,
type ComboboxGroupProps,
ComboboxItem,
type ComboboxItemProps,
ComboboxLabel,
type ComboboxLabelProps,
ComboboxList,
type ComboboxListProps,
ComboboxSeparator,
type ComboboxSeparatorProps,
} from "./combobox/list";
export {
ComboboxInput,
type ComboboxInputProps,
ComboboxTrigger,
type ComboboxTriggerProps,
ComboboxValue,
type ComboboxValueProps,
} from "./combobox/trigger";
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
Copy the source code
"use client";
// beui.dev/components/motion/combobox
export {
ComboboxContent,
type ComboboxContentProps,
} from "./combobox/content";
export {
Combobox,
type ComboboxFilter,
type ComboboxProps,
} from "./combobox/context";
export {
ComboboxEmpty,
type ComboboxEmptyProps,
ComboboxGroup,
type ComboboxGroupProps,
ComboboxItem,
type ComboboxItemProps,
ComboboxLabel,
type ComboboxLabelProps,
ComboboxList,
type ComboboxListProps,
ComboboxSeparator,
type ComboboxSeparatorProps,
} from "./combobox/list";
export {
ComboboxInput,
type ComboboxInputProps,
ComboboxTrigger,
type ComboboxTriggerProps,
ComboboxValue,
type ComboboxValueProps,
} from "./combobox/trigger";
"use client";
import { motion, type Transition } from "motion/react";
import {
type CSSProperties,
type ReactNode,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { usePopoverPortalPosition } from "@/components/motion/popover-position";
import { cn } from "@/lib/utils";
import { useComboboxContext } from "./context";
type Side = "top" | "bottom";
type Align = "start" | "center" | "end";
// The panel uses one weighted spring for both directions, so opening and
// closing travel through the same detached geometry.
const COMBOBOX_MORPH: Transition = {
type: "spring",
duration: 0.5,
bounce: 0.22,
};
const VIEWPORT_PADDING = 8;
export interface ComboboxContentProps {
children: ReactNode;
side?: Side;
align?: Align;
sideOffset?: number;
avoidCollisions?: boolean;
className?: string;
}
export function ComboboxContent({
children,
side = "bottom",
align = "start",
sideOffset = 6,
avoidCollisions = true,
className,
}: ComboboxContentProps) {
const context = useComboboxContext("ComboboxContent");
const measureRef = useRef<HTMLDivElement>(null);
const [portalReady, setPortalReady] = useState(false);
const [actualSide, setActualSide] = useState<Side>(side);
const [morphReady, setMorphReady] = useState(false);
const layout = usePopoverPortalPosition(
context.triggerRef,
measureRef,
portalReady,
);
useEffect(() => setPortalReady(true), []);
useLayoutEffect(() => {
if (!portalReady) return;
const readyFrame = requestAnimationFrame(() => setMorphReady(true));
return () => cancelAnimationFrame(readyFrame);
}, [portalReady]);
useLayoutEffect(() => {
// Preserve the resolved side during exit, so top panels close upward.
if (!context.open || !layout) return;
if (!avoidCollisions) {
setActualSide(side);
return;
}
const below =
window.innerHeight - (layout.trigger.top + layout.trigger.height);
const above = layout.trigger.top;
if (
side === "bottom" &&
below < layout.content.height + sideOffset &&
above > below
)
setActualSide("top");
else if (
side === "top" &&
above < layout.content.height + sideOffset &&
below > above
)
setActualSide("bottom");
else setActualSide(side);
}, [avoidCollisions, context.open, layout, side, sideOffset]);
if (!portalReady) return null;
const triggerLeft = layout?.trigger.left ?? 0;
const triggerWidth = layout?.trigger.width ?? 0;
const contentWidth = layout?.content.width ?? triggerWidth;
const desiredLeft =
align === "end"
? triggerLeft + triggerWidth - contentWidth
: align === "center"
? triggerLeft + (triggerWidth - contentWidth) / 2
: triggerLeft;
const maxLeft = Math.max(
VIEWPORT_PADDING,
window.innerWidth - contentWidth - VIEWPORT_PADDING,
);
const left = Math.min(Math.max(desiredLeft, VIEWPORT_PADDING), maxLeft);
const surfaceHeight = layout?.content.height ?? 0;
return createPortal(
<motion.div
ref={context.contentRef}
data-combobox-content=""
data-side={actualSide}
aria-hidden={!context.open}
inert={!context.open}
initial={false}
animate={{
height: context.open ? surfaceHeight : 0,
opacity: context.open ? 1 : 0,
y: context.open
? actualSide === "bottom"
? sideOffset
: -sideOffset
: 0,
}}
transition={
context.reduce || !morphReady ? { duration: 0 } : COMBOBOX_MORPH
}
style={
{
left,
top:
actualSide === "bottom" && layout
? layout.trigger.top + layout.trigger.height
: undefined,
bottom:
actualSide === "top" && layout
? window.innerHeight - layout.trigger.top
: undefined,
minWidth: triggerWidth,
pointerEvents: context.open ? "auto" : "none",
transformOrigin: actualSide === "bottom" ? "top" : "bottom",
visibility: layout ? "visible" : "hidden",
"--combobox-trigger-width": `${triggerWidth}px`,
} as CSSProperties
}
className={cn(
"fixed z-[9999] w-(--combobox-trigger-width) overflow-hidden rounded-xl border border-border bg-background text-popover-foreground outline-none will-change-[height,transform]",
className,
)}
>
<motion.div
ref={measureRef}
initial={false}
animate={{ opacity: context.open ? 1 : 0 }}
transition={
context.reduce || !morphReady ? { duration: 0 } : COMBOBOX_MORPH
}
>
{children}
</motion.div>
</motion.div>,
document.body,
);
}
"use client";
import { useReducedMotion } from "motion/react";
import {
createContext,
type MutableRefObject,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
import { useActiveOption } from "./use-active-option";
export type RegisteredItem = {
value: string;
label: string;
keywords: string[];
disabled: boolean;
groupId: string | null;
id: string;
ref: MutableRefObject<HTMLButtonElement | null>;
};
export type ComboboxFilter = (
value: string,
query: string,
keywords: string[],
) => boolean;
const defaultFilter: ComboboxFilter = (value, query, keywords) => {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return true;
const haystack = [value, ...keywords].join(" ").toLocaleLowerCase();
let queryIndex = 0;
for (const character of haystack) {
if (character === needle[queryIndex]) queryIndex += 1;
if (queryIndex === needle.length) return true;
}
return false;
};
export type ComboboxContextValue = {
open: boolean;
setOpen: (open: boolean, restoreFocus?: boolean) => void;
value: string | undefined;
select: (value: string) => void;
query: string;
setQuery: (query: string) => void;
/** The option the list is currently pointing at, resolved during render. */
activeValue: string | null;
/**
* Records where the pointer or keyboard moved to. The recorded value is
* dropped when the query or the result set changes, so it does not always
* survive to the next `activeValue`.
*/
setActiveValue: (value: string | null) => void;
moveActive: (direction: 1 | -1 | "first" | "last") => void;
selectActive: () => void;
registerItem: (item: RegisteredItem) => void;
unregisterItem: (value: string) => void;
labelFor: (value: string | undefined) => string | undefined;
isVisible: (value: string) => boolean;
hasVisibleItems: (groupId: string) => boolean;
visibleCount: number;
activeItemId: string | undefined;
triggerId: string;
listId: string;
inputId: string;
disabled: boolean;
reduce: boolean;
triggerRef: MutableRefObject<HTMLDivElement | null>;
contentRef: MutableRefObject<HTMLDivElement | null>;
inputRef: MutableRefObject<HTMLInputElement | null>;
activeLayoutId: string;
};
export const ComboboxContext = createContext<ComboboxContextValue | null>(null);
export const ComboboxGroupContext = createContext<string | null>(null);
export function useComboboxContext(component: string) {
const context = useContext(ComboboxContext);
if (!context) throw new Error(`${component} must be used within <Combobox>`);
return context;
}
export function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref && typeof ref === "object")
(ref as MutableRefObject<T | null>).current = node;
}
};
}
export interface ComboboxProps {
children: ReactNode;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
query?: string;
defaultQuery?: string;
onQueryChange?: (query: string) => void;
filter?: ComboboxFilter;
disabled?: boolean;
className?: string;
}
export function Combobox({
children,
value: controlledValue,
defaultValue,
onValueChange,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
query: controlledQuery,
defaultQuery = "",
onQueryChange,
filter = defaultFilter,
disabled = false,
className,
}: ComboboxProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [internalValue, setInternalValue] = useState(defaultValue);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const [internalQuery, setInternalQuery] = useState(defaultQuery);
const [items, setItems] = useState<Map<string, RegisteredItem>>(new Map());
const valueControlled = controlledValue !== undefined;
const openControlled = controlledOpen !== undefined;
const queryControlled = controlledQuery !== undefined;
const value = valueControlled ? controlledValue : internalValue;
const open = openControlled ? controlledOpen : internalOpen;
const query = queryControlled ? controlledQuery : internalQuery;
const updateQuery = useCallback(
(next: string) => {
if (!queryControlled) setInternalQuery(next);
onQueryChange?.(next);
},
[onQueryChange, queryControlled],
);
const updateOpen = useCallback(
(next: boolean, restoreFocus = false) => {
if (disabled && next) return;
if (!openControlled) setInternalOpen(next);
onOpenChange?.(next);
if (!next) updateQuery("");
if (restoreFocus)
requestAnimationFrame(() =>
inputRef.current?.focus({ preventScroll: true }),
);
},
[disabled, onOpenChange, openControlled, updateQuery],
);
const registerItem = useCallback((item: RegisteredItem) => {
setItems((current) => {
const existing = current.get(item.value);
if (
existing?.label === item.label &&
existing.disabled === item.disabled &&
existing.id === item.id &&
existing.ref === item.ref &&
existing.groupId === item.groupId &&
existing.keywords.join("\u0000") === item.keywords.join("\u0000")
) {
return current;
}
const next = new Map(current);
next.set(item.value, item);
return next;
});
}, []);
const unregisterItem = useCallback((itemValue: string) => {
setItems((current) => {
if (!current.has(itemValue)) return current;
const next = new Map(current);
next.delete(itemValue);
return next;
});
}, []);
// The query the list filters by. Closing clears `query`, but the panel is
// still on screen for its exit, so it keeps filtering by the query it was
// open with rather than repopulating mid-collapse. The input already reads
// `query` only while open, so nothing the user can see reads the other one.
const [openQuery, setOpenQuery] = useState(query);
if (open && openQuery !== query) setOpenQuery(query);
const listQuery = open ? query : openQuery;
const visibleItems = useMemo(
() =>
Array.from(items.values()).filter((item) =>
filter(item.value, listQuery, [item.label, ...item.keywords]),
),
[filter, items, listQuery],
);
const enabledVisibleItems = useMemo(
() => visibleItems.filter((item) => !item.disabled),
[visibleItems],
);
const visibleValues = useMemo(
() => new Set(visibleItems.map((item) => item.value)),
[visibleItems],
);
const visibleGroupIds = useMemo(
() => new Set(visibleItems.map((item) => item.groupId)),
[visibleItems],
);
const { activeValue, setActiveValue, moveActive } = useActiveOption({
open,
query: listQuery,
value,
enabledItems: enabledVisibleItems,
});
const select = useCallback(
(next: string) => {
if (items.get(next)?.disabled) return;
if (!valueControlled) setInternalValue(next);
onValueChange?.(next);
updateOpen(false, true);
},
[items, onValueChange, updateOpen, valueControlled],
);
const selectActive = useCallback(() => {
if (activeValue) select(activeValue);
}, [activeValue, select]);
useEffect(() => {
if (!open) return;
const frame = requestAnimationFrame(() =>
inputRef.current?.focus({ preventScroll: true }),
);
return () => cancelAnimationFrame(frame);
}, [open]);
useEffect(() => {
if (!activeValue || !open) return;
const item = items.get(activeValue)?.ref.current;
const list = item?.closest<HTMLElement>("[role='listbox']");
if (!item || !list) return;
const itemRect = item.getBoundingClientRect();
const listRect = list.getBoundingClientRect();
if (itemRect.top < listRect.top) list.scrollTop -= listRect.top - itemRect.top;
else if (itemRect.bottom > listRect.bottom)
list.scrollTop += itemRect.bottom - listRect.bottom;
}, [activeValue, items, open]);
useEffect(() => {
if (!open) return;
const isInside = (target: Node) =>
rootRef.current?.contains(target) || contentRef.current?.contains(target);
const onPointerDown = (event: PointerEvent) => {
if (!isInside(event.target as Node)) updateOpen(false);
};
const onFocusIn = (event: FocusEvent) => {
if (!isInside(event.target as Node)) updateOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
updateOpen(false, true);
};
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("focusin", onFocusIn);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("focusin", onFocusIn);
window.removeEventListener("keydown", onKeyDown);
};
}, [open, updateOpen]);
const activeItem = activeValue ? items.get(activeValue) : undefined;
const context = useMemo<ComboboxContextValue>(
() => ({
open,
setOpen: updateOpen,
value,
select,
query,
setQuery: updateQuery,
activeValue,
setActiveValue,
moveActive,
selectActive,
registerItem,
unregisterItem,
labelFor: (itemValue) =>
itemValue === undefined ? undefined : items.get(itemValue)?.label,
isVisible: (itemValue) => !listQuery.trim() || visibleValues.has(itemValue),
hasVisibleItems: (groupId) => visibleGroupIds.has(groupId),
visibleCount: visibleItems.length,
activeItemId: activeItem?.id,
triggerId: `${baseId}-trigger`,
listId: `${baseId}-list`,
inputId: `${baseId}-input`,
disabled,
reduce,
triggerRef,
contentRef,
inputRef,
activeLayoutId: `${baseId}-active`,
}),
[
activeItem?.id,
activeValue,
baseId,
disabled,
items,
listQuery,
moveActive,
open,
query,
reduce,
registerItem,
select,
selectActive,
setActiveValue,
unregisterItem,
updateOpen,
updateQuery,
value,
visibleItems.length,
visibleGroupIds,
visibleValues,
],
);
return (
<ComboboxContext.Provider value={context}>
<div ref={rootRef} className={cn("relative w-full", className)}>
{children}
</div>
</ComboboxContext.Provider>
);
}
"use client";
import { Check } from "lucide-react";
import { motion } from "motion/react";
import {
type ReactNode,
useContext,
useId,
useLayoutEffect,
useMemo,
useRef,
} from "react";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { ComboboxGroupContext, useComboboxContext } from "./context";
export interface ComboboxListProps {
children: ReactNode;
ariaLabel?: string;
className?: string;
}
export function ComboboxList({
children,
ariaLabel = "Options",
className,
}: ComboboxListProps) {
const context = useComboboxContext("ComboboxList");
return (
<div
id={context.listId}
role="listbox"
aria-label={ariaLabel}
className={cn(
"relative isolate max-h-64 overflow-y-auto overscroll-contain p-1.5 [-ms-overflow-style:none] scrollbar-none [&::-webkit-scrollbar]:hidden",
className,
)}>
{children}
</div>
);
}
export interface ComboboxGroupProps {
children: ReactNode;
className?: string;
}
export function ComboboxGroup({ children, className }: ComboboxGroupProps) {
const context = useComboboxContext("ComboboxGroup");
const groupId = useId();
return (
<ComboboxGroupContext.Provider value={groupId}>
<fieldset
hidden={!context.hasVisibleItems(groupId)}
className={cn("m-0 min-w-0 border-0 p-0 py-0.5", className)}>
{children}
</fieldset>
</ComboboxGroupContext.Provider>
);
}
export interface ComboboxLabelProps {
children: ReactNode;
className?: string;
}
export function ComboboxLabel({ children, className }: ComboboxLabelProps) {
const groupId = useContext(ComboboxGroupContext);
const labelClassName = cn(
"w-full px-2 py-1.5 text-[0.68rem] font-medium uppercase tracking-[0.12em] text-muted-foreground",
className,
);
return groupId ? (
<legend className={labelClassName}>{children}</legend>
) : (
<div className={labelClassName}>{children}</div>
);
}
export interface ComboboxItemProps {
value: string;
children: ReactNode;
textValue?: string;
keywords?: string[];
disabled?: boolean;
onSelect?: (value: string) => void;
className?: string;
}
export function ComboboxItem({
value,
children,
textValue,
keywords = [],
disabled = false,
onSelect,
className,
}: ComboboxItemProps) {
const context = useComboboxContext("ComboboxItem");
const groupId = useContext(ComboboxGroupContext);
const id = useId();
const itemRef = useRef<HTMLButtonElement>(null);
const label = textValue ?? (typeof children === "string" ? children : value);
const visible = context.isVisible(value);
const active = context.activeValue === value;
const selected = context.value === value;
const keywordKey = keywords.join("\u0000");
const normalizedKeywords = useMemo(
() => (keywordKey ? keywordKey.split("\u0000") : []),
[keywordKey],
);
const { registerItem, unregisterItem } = context;
useLayoutEffect(() => {
registerItem({
value,
label,
keywords: normalizedKeywords,
disabled,
groupId,
id,
ref: itemRef,
});
return () => unregisterItem(value);
}, [
disabled,
groupId,
id,
label,
normalizedKeywords,
registerItem,
unregisterItem,
value,
]);
if (!visible) return null;
return (
<button
ref={itemRef}
id={id}
type="button"
role="option"
aria-selected={selected}
disabled={disabled}
tabIndex={-1}
data-combobox-item=""
data-active={active || undefined}
data-selected={selected || undefined}
onPointerMove={() => {
if (!disabled) context.setActiveValue(value);
}}
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
if (disabled) return;
onSelect?.(value);
context.select(value);
}}
className={cn(
"relative flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm outline-none transition-colors duration-150",
active ? "text-foreground" : "text-muted-foreground",
"disabled:pointer-events-none disabled:opacity-45",
className,
)}>
{active ? (
<motion.span
aria-hidden
layoutId={context.activeLayoutId}
className="absolute inset-0 -z-10 rounded-lg bg-muted"
transition={context.reduce ? { duration: 0 } : SPRING_LAYOUT}
/>
) : null}
<span className="min-w-0 flex-1">{children}</span>
<motion.span
aria-hidden
initial={false}
animate={{
opacity: selected ? 1 : 0,
transform: selected ? "scale(1)" : "scale(0.82)",
}}
transition={
context.reduce ? { duration: 0 } : { duration: 0.14, ease: EASE_OUT }
}
className="grid size-5 shrink-0 place-items-center text-foreground">
<Check className="size-4" />
</motion.span>
</button>
);
}
export interface ComboboxEmptyProps {
children?: ReactNode;
className?: string;
}
export function ComboboxEmpty({
children = "No options found.",
className,
}: ComboboxEmptyProps) {
const context = useComboboxContext("ComboboxEmpty");
if (context.visibleCount > 0) return null;
return (
<div
role="status"
className={cn(
"px-3 py-8 text-center text-sm text-muted-foreground",
className,
)}>
{children}
</div>
);
}
export interface ComboboxSeparatorProps {
className?: string;
}
export function ComboboxSeparator({ className }: ComboboxSeparatorProps) {
return (
<div aria-hidden className={cn("-mx-1 my-1 h-px bg-border", className)} />
);
}
"use client";
import { ChevronsUpDown, Search } from "lucide-react";
import type {
InputHTMLAttributes,
KeyboardEvent as ReactKeyboardEvent,
ReactNode,
Ref,
} from "react";
import { cn } from "@/lib/utils";
import { mergeRefs, useComboboxContext } from "./context";
export interface ComboboxTriggerProps {
children: ReactNode;
className?: string;
}
export function ComboboxTrigger({ children, className }: ComboboxTriggerProps) {
const context = useComboboxContext("ComboboxTrigger");
return (
<div
ref={context.triggerRef}
id={context.triggerId}
data-state={context.open ? "open" : "closed"}
onPointerDown={(event) => {
if (context.disabled || event.target === context.inputRef.current) return;
event.preventDefault();
context.inputRef.current?.focus({ preventScroll: true });
context.setOpen(true);
}}
className={cn(
"relative z-20 flex h-10 w-full min-w-52 cursor-text items-center justify-between gap-3 rounded-xl border border-border bg-transparent px-3 text-sm text-foreground transition-[border-color] hover:border-(--color-border-strong)",
"focus-within:ring-2 focus-within:ring-foreground/20",
context.disabled && "pointer-events-none opacity-50",
className,
)}
>
<span className="min-w-0 flex-1 text-left">{children}</span>
<span aria-hidden className="shrink-0 text-muted-foreground">
<ChevronsUpDown className="size-4" />
</span>
</div>
);
}
export interface ComboboxValueProps {
placeholder?: ReactNode;
children?:
| ReactNode
| ((value: string | undefined, label: string | undefined) => ReactNode);
className?: string;
}
export function ComboboxValue({
placeholder = "Select an option",
children,
className,
}: ComboboxValueProps) {
const context = useComboboxContext("ComboboxValue");
const label = context.labelFor(context.value);
const content =
typeof children === "function"
? children(context.value, label)
: children ?? label ?? placeholder;
return (
<span
className={cn(
"block truncate",
context.value === undefined
? "text-muted-foreground"
: "text-foreground",
className,
)}
>
{content}
</span>
);
}
export interface ComboboxInputProps
extends Omit<
InputHTMLAttributes<HTMLInputElement>,
"defaultValue" | "value"
> {
ref?: Ref<HTMLInputElement>;
wrapperClassName?: string;
}
export function ComboboxInput({
ref,
className,
wrapperClassName,
"aria-label": ariaLabel = "Search options",
onChange,
onClick,
onFocus,
onKeyDown,
onPointerDown,
placeholder = "Search…",
...props
}: ComboboxInputProps) {
const context = useComboboxContext("ComboboxInput");
const selectedLabel = context.labelFor(context.value);
const handleKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
// Opening is the whole action. While closed the list is still filtering
// by the query the last session left, so a step taken here would be
// measured against rows the next render replaces — and stamped with a
// query it no longer has, which discards it. Open onto the selection,
// and let the next key step through the list the user can see.
if (!context.open) {
context.setOpen(true);
return;
}
context.moveActive(event.key === "ArrowDown" ? 1 : -1);
} else if (event.key === "Home" && context.open) {
event.preventDefault();
context.moveActive("first");
} else if (event.key === "End" && context.open) {
event.preventDefault();
context.moveActive("last");
} else if (event.key === "Enter") {
event.preventDefault();
if (context.open) context.selectActive();
else context.setOpen(true);
} else if (event.key === "Escape" && context.open) {
event.preventDefault();
context.setOpen(false, true);
}
};
return (
<div
className={cn(
"flex min-w-0 flex-1 items-center gap-2",
wrapperClassName,
)}
>
<Search aria-hidden className="size-4 shrink-0 text-muted-foreground" />
<input
{...props}
ref={mergeRefs(ref, context.inputRef)}
id={context.inputId}
role="combobox"
aria-label={ariaLabel}
aria-autocomplete="list"
aria-expanded={context.open}
aria-controls={context.listId}
aria-activedescendant={
context.open ? context.activeItemId : undefined
}
autoComplete="off"
disabled={context.disabled}
value={context.open ? context.query : (selectedLabel ?? "")}
placeholder={placeholder}
onPointerDown={(event) => {
onPointerDown?.(event);
if (event.defaultPrevented || context.open) return;
event.preventDefault();
context.inputRef.current?.focus({ preventScroll: true });
context.setOpen(true);
}}
onFocus={(event) => {
context.setOpen(true);
onFocus?.(event);
}}
onClick={(event) => {
context.setOpen(true);
onClick?.(event);
}}
onChange={(event) => {
context.setOpen(true);
context.setQuery(event.target.value);
onChange?.(event);
}}
onKeyDown={handleKeyDown}
className={cn(
"h-10 min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed",
className,
)}
/>
</div>
);
}
"use client";
import {
type MutableRefObject,
useCallback,
useLayoutEffect,
useState,
} from "react";
export type PortalLayout = {
trigger: {
left: number;
top: number;
width: number;
height: number;
};
content: {
width: number;
height: number;
};
};
function sameLayout(a: PortalLayout | null, b: PortalLayout) {
return (
a?.trigger.left === b.trigger.left &&
a.trigger.top === b.trigger.top &&
a.trigger.width === b.trigger.width &&
a.trigger.height === b.trigger.height &&
a.content.width === b.content.width &&
a.content.height === b.content.height
);
}
/** Measures a trigger and portalled panel in viewport coordinates. */
export function usePopoverPortalPosition<
TriggerElement extends HTMLElement,
ContentElement extends HTMLElement,
>(
triggerRef: MutableRefObject<TriggerElement | null>,
contentRef: MutableRefObject<ContentElement | null>,
active: boolean,
) {
const [layout, setLayout] = useState<PortalLayout | null>(null);
const update = useCallback(() => {
const trigger = triggerRef.current;
const content = contentRef.current;
if (!trigger || !content) return;
const rect = trigger.getBoundingClientRect();
const next: PortalLayout = {
trigger: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
content: {
width: content.offsetWidth,
height: content.offsetHeight,
},
};
setLayout((current) => (sameLayout(current, next) ? current : next));
}, [contentRef, triggerRef]);
useLayoutEffect(() => {
update();
if (!active) return;
const trigger = triggerRef.current;
const content = contentRef.current;
const observer = new ResizeObserver(update);
if (trigger) observer.observe(trigger);
if (content) observer.observe(content);
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [active, contentRef, triggerRef, update]);
return layout;
}
"use client";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
/**
* Where the keyboard or the pointer last moved to, stamped with the query it
* was placed under. Which option is *active* is resolved from this during
* render, never in an effect: a passive effect runs after the commit, so a list
* would briefly have none of its options active, and a key arriving in that
* window would move from nowhere onto the row it was already about to
* highlight.
*/
type ActiveCursor = { value: string; query: string };
type Options = {
query: string;
value: string | undefined;
/** The enabled, visible options in list order — all this hook reads of them. */
enabledItems: readonly { value: string }[];
};
const isEnabled = (
enabledItems: Options["enabledItems"],
candidate: string | undefined,
): candidate is string =>
candidate !== undefined && enabledItems.some((i) => i.value === candidate);
/**
* The cursor's option, or null once the query or the result set it was placed
* in has changed. A cursor that outlived either would steal Enter from the row
* the user is aiming at. The result-set half costs something: a live search
* that blanks its rows while fetching and returns the same ones loses the moved
* highlight. That is deliberate — a highlight visibly back at the top beats one
* silently in the wrong place.
*
* It is stamped with the query rather than with the identity of the visible
* list because callers routinely pass an inline `filter`, which makes that list
* a fresh array on every render.
*/
function liveCursorValue(cursor: ActiveCursor | null, options: Options) {
if (cursor === null || cursor.query !== options.query) return null;
return isEnabled(options.enabledItems, cursor.value) ? cursor.value : null;
}
/**
* Where the highlight sits with no live cursor: the selection if it can be
* selected, otherwise the first option that can. Only enabled options qualify —
* an active disabled option would point `aria-activedescendant` at a row Enter
* then refuses to select.
*/
function fallbackActive({ value, enabledItems }: Options) {
return isEnabled(enabledItems, value) ? value : (enabledItems[0]?.value ?? null);
}
/** The active option, from a cursor that may or may not still be live. */
const resolveActive = (cursor: ActiveCursor | null, options: Options) =>
liveCursorValue(cursor, options) ?? fallbackActive(options);
export function useActiveOption({ open, ...options }: Options & { open: boolean }) {
const { query, value, enabledItems } = options;
const [cursor, setCursor] = useState<ActiveCursor | null>(null);
const live = liveCursorValue(cursor, options);
// Cleared rather than ignored: React re-runs this render with the cursor
// gone, so a value that reappears later cannot revive it.
if (cursor !== null && live === null) setCursor(null);
const derived = live ?? fallbackActive(options);
// Nothing is active until the list has been opened once. After that the
// resolution above is already stable across a close — the list keeps
// filtering by the query it was open with — so the highlight holds its row
// through the exit without being frozen separately.
const [opened, setOpened] = useState(open);
if (open && !opened) setOpened(true);
const activeValue = opened ? derived : null;
// Both callbacks keep one identity for the life of the component, and read
// the list through a ref to do it. A caller will put them in a `useMemo` or
// an effect's dependencies — the exhaustive-deps rule makes it — and
// `enabledItems` is a fresh array on every render for any consumer passing an
// inline `filter`, so a callback keyed to it would be rebuilt every render.
// Written after commit rather than during render: a render React discards
// still runs the component body, and a handler reading this in that window
// would step against a list the committed tree does not have.
const latest = useRef({ open, query, value, enabledItems });
useLayoutEffect(() => {
latest.current = { open, query, value, enabledItems };
});
const setActiveValue = useCallback((next: string | null) => {
setCursor(
next === null ? null : { value: next, query: latest.current.query },
);
}, []);
// Steps from the option the cursor really resolves to, inside the update, so
// that two keys landing in one batch move two rows rather than one.
const moveActive = useCallback(
(direction: 1 | -1 | "first" | "last") => {
const options = latest.current;
// While closed the list is still filtering by the query it was open
// with, so a step taken now would be measured against rows the next
// render replaces. Opening is the caller's job; stepping waits for it.
if (!options.open) return;
const rows = options.enabledItems;
const last = rows.length - 1;
if (last < 0) {
setCursor(null);
return;
}
setCursor((current) => {
// `resolveActive` always lands on a member of `enabledItems` once the
// list is non-empty, which the early return above guarantees, so there
// is always a row to step from.
const from = resolveActive(current, options);
const at = rows.findIndex((item) => item.value === from);
const index =
direction === "first"
? 0
: direction === "last"
? last
: (at + direction + rows.length) % rows.length;
return { value: rows[index].value, query: options.query };
});
},
[],
);
return { activeValue, setActiveValue, moveActive };
}
API Reference
ComboboxContent
side?"top" | "bottom"bottomalign?"center" | "end" | "start"startsideOffset?number6avoidCollisions?booleantrueclassName?string—Combobox
value?string—defaultValue?string—onValueChange?((value: string) => void)—open?boolean—defaultOpen?booleanfalseonOpenChange?((open: boolean) => void)—query?string—defaultQuery?stringonQueryChange?((query: string) => void)—filter?ComboboxFilter(value, query, keywords) => {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return true;
const haystack = [value, ...keywords].join(" ").toLocaleLowerCase();
let queryIndex = 0;
for (const character of haystack) {
if (character === needle[queryIndex]) queryIndex += 1;
if (queryIndex === needle.length) return true;
}
return false;
}disabled?booleanfalseclassName?string—ComboboxEmpty
className?string—ComboboxGroup
className?string—ComboboxItem
valuestring—textValue?string—keywords?string[][]disabled?booleanfalseonSelect?((value: string) => void)—className?string—ComboboxLabel
className?string—ComboboxList
ariaLabel?stringOptionsclassName?string—ComboboxSeparator
className?string—ComboboxInput
ref?Ref<HTMLInputElement>—wrapperClassName?string—className?string—ComboboxTrigger
className?string—ComboboxValue
placeholder?ReactNodeSelect an optionclassName?string—Composition
Place the searchable input inside the trigger, then compose listbox primitives inside the portalled content.
Combobox
├── ComboboxTrigger
│ └── ComboboxInput
└── ComboboxContent
└── ComboboxList
├── ComboboxEmpty
├── ComboboxGroup
│ ├── ComboboxLabel
│ └── ComboboxItem
└── ComboboxSeparatorHow it works
A combobox combines text input with a filtered listbox while keeping focus in the input. It manages search, active-option navigation, selection, and portal positioning without owning the option data or surrounding form.
Related components
Multi Select
Composable multi-select primitives with searchable options, removable animated tokens, and a morphing collision-aware panel.
Sortable List
Composable sortable list with group, item, drag handle, content and undo parts, plus a shared state hook. Spring layout, keyboard reordering, accessible position announcements, and controlled or uncontrolled items.
Image Viewer
A composable React image viewer for galleries and attachment previews, with animated lightbox variants, custom thumbnails and controls, swipe and keyboard navigation, zoom and pan, accessible focus management, and controlled or uncontrolled selection.
Updated