Combobox
Searchable combobox with a morphing portal, grouped filtering, keyboard navigation, and controlled or uncontrolled state.
Preview
Workspace
TSXcomponents/previews/motion/combobox.preview.tsx
"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>
);
}
TSXcomponents/motion/combobox.tsx
"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.
$ bunx --bun shadcn add @beui/combobox
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
TSXlib/ease.ts
// 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
TSXcomponents/motion/combobox.tsx
"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";
TSXcomponents/motion/combobox/content.tsx
"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,
);
}
TSXcomponents/motion/combobox/context.tsx
"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>
);
}
TSXcomponents/motion/combobox/list.tsx
"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)} />
);
}
TSXcomponents/motion/combobox/trigger.tsx
"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>
);
}
TSXcomponents/motion/popover-position.ts
"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;
}
TSXcomponents/motion/combobox/use-active-option.ts
"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?"bottom" | "top"bottomalign?"end" | "start" | "center"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.
Updated