Combobox
An autocomplete input that combines a text field with a filterable dropdown list.
source cached 2026-10-19
ui/combobox.tsx
"use client";
import { Combobox as ComboboxPrimitive } from "@base-ui/react";
import { CheckIcon, ChevronDownIcon, CrossSmallIcon, XIcon } from "blode-icons-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
const Combobox = ComboboxPrimitive.Root;
const ComboboxValue = ({ ...props }: ComboboxPrimitive.Value.Props) => (
<ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
);
const ComboboxTrigger = ({
className,
children,
render,
...props
}: ComboboxPrimitive.Trigger.Props) => (
<ComboboxPrimitive.Trigger
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
data-slot="combobox-trigger"
render={
render ?? <InputGroupButton data-slot="input-group-button" size="icon-xs" variant="ghost" />
}
{...props}
>
{children ?? <ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />}
</ComboboxPrimitive.Trigger>
);
const ComboboxClear = ({ className, ...props }: ComboboxPrimitive.Clear.Props) => (
<ComboboxPrimitive.Clear
className={cn(className)}
data-slot="combobox-clear"
render={<InputGroupButton size="icon-xs" variant="ghost" />}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
);
const ComboboxInput = ({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean;
showClear?: boolean;
}) => (
<ComboboxPrimitive.InputGroup
render={
<InputGroup
className={cn("h-[var(--field-height)] w-auto rounded-[var(--field-radius)]", className)}
/>
}
>
<ComboboxPrimitive.Input render={<InputGroupInput disabled={disabled} />} {...props} />
<InputGroupAddon align="inline-end">
{showTrigger && (
<ComboboxTrigger
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</ComboboxPrimitive.InputGroup>
);
const ComboboxContent = ({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) => (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
side={side}
sideOffset={sideOffset}
>
<ComboboxPrimitive.Popup
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 cn-menu-target group/combobox-content relative flex max-h-(--available-height) w-(--anchor-width) min-w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) flex-col overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-soft ring-1 ring-foreground/10 data-closed:animate-out data-open:animate-in data-closed:duration-75 data-open:duration-100 data-closed:ease-in data-open:ease-out *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none",
className,
)}
data-slot="combobox-content"
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
);
interface ComboboxHighlightRect {
height: number;
left: number;
top: number;
width: number;
}
interface ComboboxHighlightState {
/** The last row the highlight was told about; kept while it fades out. */
rect: ComboboxHighlightRect | null;
/** False makes the next move a jump rather than a slide. */
travels: boolean;
visible: boolean;
}
const HIGHLIGHT_HIDDEN: ComboboxHighlightState = {
rect: null,
travels: false,
visible: false,
};
/**
* Offsets, not client rects: the list is the items' `offsetParent`, so these
* are already scroll-inclusive and the highlight rides the scroll without a
* scroll listener.
*/
const sameHighlightRect = (a: ComboboxHighlightRect, b: ComboboxHighlightRect) =>
a.height === b.height && a.left === b.left && a.top === b.top && a.width === b.width;
const readHighlightRect = (list: HTMLElement): ComboboxHighlightRect | null => {
const item = list.querySelector<HTMLElement>('[data-slot="combobox-item"][data-highlighted]');
if (!item) {
return null;
}
return {
height: item.offsetHeight,
left: item.offsetLeft,
top: item.offsetTop,
width: item.offsetWidth,
};
};
/**
* One bar that slides between rows, rather than a background that blinks off
* and on in every gap. Base UI writes `data-highlighted` for the pointer and
* the arrow keys alike, so a single observer drives both.
*
* The travel is a transform on one element, so a move costs no layout. Rows
* appearing or disappearing under a filter make the bar jump instead of slide,
* because sliding to a row that just moved reads as a glitch. The global
* reduced-motion stylesheet overrides the inline duration with `!important`.
*
* The resize observer is not optional: on open the popup paints at its content
* width for a frame before `--anchor-width` lands, and a bar measured in that
* frame would keep the narrow width forever, since widening the row mutates
* neither `data-highlighted` nor the child list.
*/
const ComboboxList = ({ className, ...props }: ComboboxPrimitive.List.Props) => {
const listRef = React.useRef<HTMLDivElement>(null);
const [highlight, setHighlight] = React.useState(HIGHLIGHT_HIDDEN);
React.useEffect(() => {
const list = listRef.current;
if (!list) {
return;
}
const sync = (jump: boolean) => {
const rect = readHighlightRect(list);
setHighlight((previous) => {
if (!rect) {
return previous.visible ? { ...previous, travels: false, visible: false } : previous;
}
if (previous.visible && previous.rect && sameHighlightRect(previous.rect, rect)) {
return previous;
}
return { rect, travels: !jump && previous.visible, visible: true };
});
};
sync(true);
const observer = new MutationObserver((records) => {
sync(records.some((record) => record.type === "childList"));
});
observer.observe(list, {
attributeFilter: ["data-highlighted"],
attributes: true,
childList: true,
subtree: true,
});
const resizeObserver = new ResizeObserver(() => sync(true));
resizeObserver.observe(list);
return () => {
observer.disconnect();
resizeObserver.disconnect();
};
}, []);
const { rect, travels, visible } = highlight;
return (
<ComboboxPrimitive.List
className={cn(
"scroll-fade relative max-h-72 min-h-0 flex-auto scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className,
)}
data-slot="combobox-list"
ref={listRef}
render={({ children, ...listProps }) => (
<div {...listProps}>
{rect && (
<div
aria-hidden="true"
className="pointer-events-none absolute top-0 left-0 ease-out will-change-transform"
data-slot="combobox-highlight"
style={{
height: rect.height,
transform: `translate3d(${rect.left}px, ${rect.top}px, 0)`,
transitionDuration: travels ? "80ms" : "0ms",
transitionProperty: "transform, width, height",
width: rect.width,
}}
>
<div
className={cn(
"size-full rounded-md bg-accent transition-opacity duration-100 ease-out",
visible ? "opacity-100" : "opacity-0",
)}
/>
</div>
)}
{children}
</div>
)}
{...props}
/>
);
};
const ComboboxItem = ({ className, children, ...props }: ComboboxPrimitive.Item.Props) => (
<ComboboxPrimitive.Item
className={cn(
"relative flex w-full cursor-default select-none items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-sm outline-hidden data-disabled:pointer-events-none data-highlighted:text-accent-foreground data-disabled:opacity-50 not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
data-slot="combobox-item"
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center transition-[opacity,transform] duration-100 ease-out data-ending-style:scale-75 data-starting-style:scale-75 data-ending-style:opacity-0 data-starting-style:opacity-0" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
);
const ComboboxGroup = ({ className, ...props }: ComboboxPrimitive.Group.Props) => (
<ComboboxPrimitive.Group className={cn(className)} data-slot="combobox-group" {...props} />
);
const ComboboxLabel = ({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) => (
<ComboboxPrimitive.GroupLabel
className={cn("px-2 py-1.5 text-muted-foreground text-xs", className)}
data-slot="combobox-label"
{...props}
/>
);
const ComboboxCollection = ({ ...props }: ComboboxPrimitive.Collection.Props) => (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
);
const ComboboxEmpty = ({ className, ...props }: ComboboxPrimitive.Empty.Props) => (
<ComboboxPrimitive.Empty
className={cn("px-3 text-center text-muted-foreground text-sm [&:not(:empty)]:py-6", className)}
data-slot="combobox-empty"
{...props}
/>
);
const ComboboxSeparator = ({ className, ...props }: ComboboxPrimitive.Separator.Props) => (
<ComboboxPrimitive.Separator
className={cn("-mx-1 my-1 h-px bg-border", className)}
data-slot="combobox-separator"
{...props}
/>
);
const ComboboxChipsInput = ({ className, ...props }: ComboboxPrimitive.Input.Props) => (
<ComboboxPrimitive.Input
className={cn("min-w-16 flex-1 outline-none", className)}
data-slot="combobox-chip-input"
{...props}
/>
);
/**
* The fast tier of the motion scale `ask-user-questions` already keeps: a chip
* is a small thing, so it moves quickly, and it leaves a step quicker than it
* arrives, so a removal reads as final rather than as the entrance in reverse.
*/
const CHIP_SPRING = { bounce: 0, duration: 0.08, type: "spring" as const };
const CHIP_EXIT = { duration: 0.06 };
const NO_MOTION = { duration: 0 };
type ComboboxChipsProps<Value> = Omit<ComboboxPrimitive.Chips.Props, "children"> & {
/**
* Renders one keyed `ComboboxChip` per selected value. The array, not a
* fragment: `AnimatePresence` only tracks children it can see.
*/
children: (value: Value) => React.ReactElement[];
placeholder?: string;
};
/**
* The chips and the input are rendered here rather than by the consumer for
* two reasons: an exit animation needs `AnimatePresence` to be the chips'
* direct parent, and the placeholder has to know whether anything is selected.
*
* `mode="popLayout"` lifts a leaving chip out of the flow at once, so the row
* closes the gap while it fades instead of after it, and `layout` on each
* wrapper slides the survivors into their new slots. The motion wrapper sits
* outside `ComboboxChip` so that the elements `AnimatePresence` measures are
* motion components it can take a ref to.
*
* The horizontal padding stays `--field-padding-x` in both states. Tightening
* it once a chip exists moves the row's leading edge, so the placeholder and
* the first chip start at different offsets and the field shifts under the
* cursor as the last chip goes.
*/
const ComboboxChips = <Value,>({
className,
children,
placeholder,
...props
}: ComboboxChipsProps<Value>) => {
const reduceMotion = useReducedMotion();
return (
<ComboboxPrimitive.InputGroup
className={cn(
"flex min-h-[var(--field-height)] cursor-text flex-wrap items-center gap-1 rounded-[var(--field-radius)] border border-input bg-transparent bg-clip-padding px-[var(--field-padding-x)] py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className,
)}
>
<ComboboxPrimitive.Chips
className="relative flex w-full flex-wrap items-center gap-1"
data-slot="combobox-chips"
{...props}
>
<ComboboxPrimitive.Value>
{(value: Value) => (
<>
<AnimatePresence initial={false} mode="popLayout">
{children(value).map((chip) => (
<motion.span
animate={{ opacity: 1, scale: 1 }}
className="inline-flex max-w-full shrink-0"
exit={{
opacity: 0,
pointerEvents: "none",
scale: 0.9,
transition: reduceMotion ? NO_MOTION : CHIP_EXIT,
}}
initial={{ opacity: 0, scale: 0.9 }}
key={chip.key}
layout={!reduceMotion}
transition={reduceMotion ? NO_MOTION : CHIP_SPRING}
>
{chip}
</motion.span>
))}
</AnimatePresence>
<ComboboxChipsInput
placeholder={Array.isArray(value) && value.length > 0 ? undefined : placeholder}
/>
</>
)}
</ComboboxPrimitive.Value>
</ComboboxPrimitive.Chips>
</ComboboxPrimitive.InputGroup>
);
};
/**
* The chip carries no animation of its own: `ComboboxChips` wraps each one in
* the motion element that enters, leaves, and slides between slots.
*/
const ComboboxChip = ({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean;
}) => (
<ComboboxPrimitive.Chip
className={cn(
"flex h-6 w-fit items-center justify-center gap-1 whitespace-nowrap rounded-sm bg-muted px-1.5 font-medium text-foreground text-xs has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-data-[slot=combobox-chip-remove]:pr-0.5 has-disabled:opacity-50",
className,
)}
data-slot="combobox-chip"
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
/* `icon-xs` is a 32px button, which overhangs a 24px chip on every
side; the override keeps the hover wash inside it. */
render={<Button className="size-5 rounded-sm" size="icon-xs" variant="ghost" />}
>
<CrossSmallIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
);
const useComboboxAnchor = () => React.useRef<HTMLDivElement | null>(null);
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
};
- Interaction
- dropdown
DependenciesBundle weight · medium
- ui-primitives
- @base-ui/react
- animation
- motion
Registry dependencies
buttoninput-group