Combobox
A select you type into: the input is the trigger, items filter by label or extra keywords across groups, arrow keys move a sliding highlight, and the list springs open in a portal that holds its side while closing. Value, open state and query are controllable.
Preview
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";
// karaui.kamisama-v0.my.id/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(
"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.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;
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
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
The trigger wraps the text field. Items, groups, labels, separators and the empty message go in the content, which renders through a body portal.
Combobox
├── ComboboxTrigger
│ └── ComboboxInput
└── ComboboxContent
└── ComboboxList
├── ComboboxEmpty
├── ComboboxGroup
│ ├── ComboboxLabel
│ └── ComboboxItem
└── ComboboxSeparatorHow it works
Use it when a plain select has too many options to scroll. The text field is the trigger, so people type to shrink the list and never leave the field. The component tracks the query, the highlighted row and the chosen value; your app supplies the options and whatever form wraps them.
Updated