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";
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;
activeValue: string | null;
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 [activeValue, setActiveValue] = useState<string | null>(null);
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;
});
}, []);
const visibleItems = useMemo(
() =>
Array.from(items.values()).filter((item) =>
filter(item.value, query, [item.label, ...item.keywords]),
),
[filter, items, query],
);
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 select = useCallback(
(next: string) => {
if (items.get(next)?.disabled) return;
if (!valueControlled) setInternalValue(next);
onValueChange?.(next);
updateOpen(false, true);
},
[items, onValueChange, updateOpen, valueControlled],
);
const moveActive = useCallback(
(direction: 1 | -1 | "first" | "last") => {
if (!enabledVisibleItems.length) {
setActiveValue(null);
return;
}
if (direction === "first") {
setActiveValue(enabledVisibleItems[0].value);
return;
}
if (direction === "last") {
setActiveValue(enabledVisibleItems.at(-1)?.value ?? null);
return;
}
const currentIndex = enabledVisibleItems.findIndex(
(item) => item.value === activeValue,
);
const nextIndex =
currentIndex < 0
? direction === 1
? 0
: enabledVisibleItems.length - 1
: (currentIndex + direction + enabledVisibleItems.length) %
enabledVisibleItems.length;
setActiveValue(enabledVisibleItems[nextIndex].value);
},
[activeValue, enabledVisibleItems],
);
const selectActive = useCallback(() => {
if (activeValue) select(activeValue);
}, [activeValue, select]);
useEffect(() => {
if (!open) return;
const selectedVisible = value && visibleValues.has(value) ? value : null;
const activeVisible =
activeValue && visibleValues.has(activeValue) ? activeValue : null;
setActiveValue(
activeVisible ?? selectedVisible ?? enabledVisibleItems[0]?.value ?? null,
);
}, [activeValue, enabledVisibleItems, open, value, visibleValues]);
useEffect(() => {
if (!open) return;
requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true }));
}, [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) => !query.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,
moveActive,
open,
query,
reduce,
registerItem,
select,
selectActive,
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(
"max-h-64 overflow-y-auto overscroll-contain p-1.5 [-ms-overflow-style:none] [scrollbar-width: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 isolate 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.preventDefault();
context.setOpen(true);
context.moveActive(1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
context.setOpen(true);
context.moveActive(-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;
}
API Reference
ComboboxContent
side?"top" | "bottom"bottomalign?"start" | "center" | "end"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?{}[]disabled?booleanfalseonSelect?((value: string) => void)—className?string—ComboboxLabel
className?string—ComboboxList
ariaLabel?stringOptionsclassName?string—ComboboxSeparator
className?string—ComboboxInput
ref?any—wrapperClassName?string—ComboboxTrigger
className?string—ComboboxValue
placeholder?anySelect 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.
Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated