Combobox
A single-select field with a searchable list, built from Popover and Command. Use it when there are too many options to scan, or when people know what they are looking for.
Installation
Add Combobox to your project with the CK-UI CLI. It copies the source into your project, so the code is yours to change.
npx ck-ui-cli@latest add combobox- Adds
Combobox.tsxCommand.tsxField.tsxLabel.tsxPopover.tsxThemeScope.tsxto your CK-UI folder (src/components/ck-ui by default)- Installs
@radix-ui/react-label@radix-ui/react-popover@radix-ui/react-slotcmdkreact-iconsif your project does not have them yet
First time with CK-UI in this project? Run npx ck-ui-cli@latest init once before adding components. The installation guide explains what it sets up.
Manual installationInstall the packages and copy the files yourself.
Install the following dependencies
Run these commands in your terminal to install the required packages
npm install react-icons cmdk @radix-ui/react-popover @radix-ui/react-slot @radix-ui/react-labelAdd the Command component
Combobox imports ./Command, so add this file next to it
"use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { RiSearchLine } from "react-icons/ri";
import { cn } from "@/lib/utils";
/*
* A searchable list of actions or options, built on cmdk:
* Command.Input → role="combobox" controlling the list (aria-activedescendant, aria-expanded)
* Command.List → role="listbox"; Command.Item → role="option" (aria-selected marks the highlighted item)
* Arrow keys move the highlight, Enter selects, Home/End jump. Filtering is built in (set shouldFilter={false}
* to filter yourself). It is a generic primitive: it knows nothing about routing or CK-UI's docs.
*/
export type CommandRootProps = React.ComponentPropsWithoutRef<typeof CommandPrimitive>;
/** `label` names the list for assistive technology. */
const Root = React.forwardRef<React.ElementRef<typeof CommandPrimitive>, CommandRootProps>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex w-full flex-col overflow-hidden bg-surface text-surface-foreground",
"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pb-1.5 [&_[cmdk-group-heading]]:pt-2 [&_[cmdk-group-heading]]:size-subheading-2xs [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
));
Root.displayName = "Command.Root";
export interface CommandInputProps extends React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> {
/** Classes for the row around the input (icon + input + children). */
wrapperClassName?: string;
/** Hide the leading search icon. */
hideIcon?: boolean;
/** Rendered after the input, e.g. a keyboard hint. */
children?: React.ReactNode;
}
const Input = React.forwardRef<React.ElementRef<typeof CommandPrimitive.Input>, CommandInputProps>(({ className, wrapperClassName, hideIcon = false, children, ...props }, ref) => (
<div className={cn("flex items-center gap-2 border-b border-border px-3", wrapperClassName)}>
{!hideIcon && <RiSearchLine aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />}
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full border-none bg-transparent p-0 size-p-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-0",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
{children}
</div>
));
Input.displayName = "Command.Input";
const List = React.forwardRef<React.ElementRef<typeof CommandPrimitive.List>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>>(({ className, ...props }, ref) => (
<CommandPrimitive.List ref={ref} className={cn("max-h-80 overflow-y-auto overflow-x-hidden p-1.5", className)} {...props} />
));
List.displayName = "Command.List";
const Empty = React.forwardRef<React.ElementRef<typeof CommandPrimitive.Empty>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>>(({ className, ...props }, ref) => (
<CommandPrimitive.Empty ref={ref} className={cn("py-6 text-center size-p-sm text-muted-foreground", className)} {...props} />
));
Empty.displayName = "Command.Empty";
const Loading = React.forwardRef<React.ElementRef<typeof CommandPrimitive.Loading>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.Loading>>(({ className, ...props }, ref) => (
<CommandPrimitive.Loading ref={ref} className={cn("py-6 text-center size-p-sm text-muted-foreground", className)} {...props} />
));
Loading.displayName = "Command.Loading";
/** `heading` is shown above the group and names it for assistive technology. */
const Group = React.forwardRef<React.ElementRef<typeof CommandPrimitive.Group>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>>(({ className, ...props }, ref) => (
<CommandPrimitive.Group ref={ref} className={cn("overflow-hidden py-1", className)} {...props} />
));
Group.displayName = "Command.Group";
const Separator = React.forwardRef<React.ElementRef<typeof CommandPrimitive.Separator>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>>(({ className, ...props }, ref) => (
// cmdk gives it role="separator", which a listbox may not contain; it is decorative (each group has a heading).
<CommandPrimitive.Separator ref={ref} aria-hidden="true" className={cn("-mx-1.5 my-1 h-px bg-border", className)} {...props} />
));
Separator.displayName = "Command.Separator";
/**
* An option. `onSelect` runs on Enter or click. `value` is the item's identity and is searched together with
* `keywords`; give items with the same text a unique `value`.
*/
const Item = React.forwardRef<React.ElementRef<typeof CommandPrimitive.Item>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex min-h-9 cursor-pointer select-none items-center gap-2 rounded-lg px-2 py-1.5 size-p-sm text-foreground outline-none",
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground",
"data-[disabled=true]:pointer-events-none data-[disabled=true]:text-muted-foreground data-[disabled=true]:opacity-60",
"[&>svg]:size-4 [&>svg]:shrink-0",
className
)}
{...props}
/>
));
Item.displayName = "Command.Item";
/** Keyboard shortcut hint at the end of an item. Decorative: put the shortcut in `aria-keyshortcuts` if it is real. */
const Shortcut = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(({ className, ...props }, ref) => (
<span ref={ref} aria-hidden="true" className={cn("ml-auto font-mono size-p-xs tracking-wider text-muted-foreground", className)} {...props} />
));
Shortcut.displayName = "Command.Shortcut";
export { Root, Input, List, Empty, Loading, Group, Separator, Item, Shortcut };
Add the Popover component
Combobox imports ./Popover, so add this file next to it
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { Slottable } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
import { usePortalContainer } from "./ThemeScope";
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
/** Open state that works both controlled (`open` + `onOpenChange`) and uncontrolled (`defaultOpen`). */
function useControllableOpen(open: boolean | undefined, defaultOpen: boolean | undefined, onOpenChange: ((open: boolean) => void) | undefined) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false);
const isControlled = open !== undefined;
const value = isControlled ? open : uncontrolledOpen;
const latest = React.useRef({ value, isControlled, onOpenChange });
useIsomorphicLayoutEffect(() => {
latest.current = { value, isControlled, onOpenChange };
});
const setOpen = React.useCallback((next: boolean) => {
const current = latest.current;
if (next === current.value) return;
if (!current.isControlled) setUncontrolledOpen(next);
current.onOpenChange?.(next);
}, []);
return [value, setOpen] as const;
}
/** Calls the consumer handler first; ours runs unless the consumer prevented the default. */
function composeHandlers<E extends { defaultPrevented: boolean }>(theirs: ((event: E) => void) | undefined, ours: ((event: E) => void) | undefined) {
if (!ours) return theirs;
return (event: E) => {
theirs?.(event);
if (!event.defaultPrevented) ours(event);
};
}
function setRef<T>(ref: React.Ref<T> | undefined, value: T | null) {
if (typeof ref === "function") ref(value);
else if (ref) (ref as React.MutableRefObject<T | null>).current = value;
}
const TABBABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
// ---------------------------------------------------------------------------------------------
// Sizes
// ---------------------------------------------------------------------------------------------
export type PopoverSize = "sm" | "md" | "lg";
/** @deprecated Use `sm` / `md` / `lg`. */
type LegacyPopoverSize = "small" | "medium" | "large";
const LEGACY_SIZES: Record<LegacyPopoverSize, PopoverSize> = { small: "sm", medium: "md", large: "lg" };
export function resolvePopoverSize(size?: PopoverSize | LegacyPopoverSize | null): PopoverSize {
if (!size) return "md";
return size in LEGACY_SIZES ? LEGACY_SIZES[size as LegacyPopoverSize] : (size as PopoverSize);
}
// `md` keeps the original look. The footer bleeds to the panel edges, so its offsets follow the padding.
const sizeStyles: Record<PopoverSize, { content: string; bodyWithFooter: string; footer: string }> = {
sm: { content: "p-3 size-p-xs", bodyWithFooter: "mb-3", footer: "-mx-3 -mb-3 mt-3 px-3 py-3" },
md: { content: "p-5 size-p-xs", bodyWithFooter: "mb-5", footer: "-mx-5 -mb-5 mt-5 px-5 py-4" },
lg: { content: "p-6 size-p-sm", bodyWithFooter: "mb-6", footer: "-mx-6 -mb-6 mt-6 px-6 py-4" },
};
// ---------------------------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------------------------
type OpenReason = "click" | "hover" | "focus";
interface PopoverRootContextValue {
triggerMode: "click" | "hover";
contentRef: React.MutableRefObject<HTMLDivElement | null>;
onTriggerPointerEnter: (event: React.PointerEvent) => void;
onTriggerPointerLeave: (event: React.PointerEvent) => void;
onTriggerFocus: () => void;
onTriggerBlur: (event: React.FocusEvent) => void;
onTriggerClick: (event: React.MouseEvent) => void;
onContentPointerEnter: (event: React.PointerEvent) => void;
onContentPointerLeave: (event: React.PointerEvent) => void;
onContentFocus: () => void;
onContentOpenAutoFocus: (event: Event) => void;
onContentCloseAutoFocus: (event: Event) => void;
}
const PopoverRootContext = React.createContext<PopoverRootContextValue | null>(null);
const PopoverContext = React.createContext<{
hasFooter: boolean;
setHasFooter: React.Dispatch<React.SetStateAction<boolean>>;
size: PopoverSize;
} | null>(null);
// ---------------------------------------------------------------------------------------------
// Root
// ---------------------------------------------------------------------------------------------
export interface RootProps extends React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Root> {
/**
* `click` (default): the trigger toggles the popover and focus moves into it.
* `hover`: opens on mouse hover and on keyboard focus of the trigger without moving focus;
* clicking (or Enter/Space on) the trigger moves focus into the popover.
*/
triggerMode?: "click" | "hover";
/** Delay in ms before opening/closing on mouse hover (hover mode). @default 200 */
hoverDelay?: number;
}
/** Works controlled (`open` + `onOpenChange`) or uncontrolled (`defaultOpen`) in both trigger modes. */
const Root = ({ children, triggerMode = "click", hoverDelay = 200, open: openProp, defaultOpen, onOpenChange, modal }: RootProps) => {
const [open, setOpen] = useControllableOpen(openProp, defaultOpen, onOpenChange);
const openRef = React.useRef(open);
useIsomorphicLayoutEffect(() => {
openRef.current = open;
});
const contentRef = React.useRef<HTMLDivElement | null>(null);
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const openReasonRef = React.useRef<OpenReason>("click");
const focusInsideRef = React.useRef(false);
const ignoreNextFocusRef = React.useRef(false);
const clearTimer = React.useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
}, []);
React.useEffect(() => clearTimer, [clearTimer]);
const show = React.useCallback(
(reason: OpenReason) => {
clearTimer();
if (openRef.current) return;
openReasonRef.current = reason;
focusInsideRef.current = false;
setOpen(true);
},
[clearTimer, setOpen]
);
const hide = React.useCallback(() => {
clearTimer();
setOpen(false);
}, [clearTimer, setOpen]);
// Changes coming from Radix: trigger click (click mode), Escape, outside interaction, Popover.Close.
const handleOpenChange = React.useCallback(
(next: boolean) => {
if (next) show("click");
else hide();
},
[show, hide]
);
const context = React.useMemo<PopoverRootContextValue>(() => {
const scheduleShow = () => {
clearTimer();
if (openRef.current) return;
timerRef.current = setTimeout(() => show("hover"), hoverDelay);
};
const scheduleHide = () => {
clearTimer();
timerRef.current = setTimeout(hide, hoverDelay);
};
const focusContent = () => {
const content = contentRef.current;
if (!content) return;
const target = content.querySelector<HTMLElement>(TABBABLE) ?? content;
target.focus();
};
return {
triggerMode,
contentRef,
onTriggerPointerEnter: (event) => {
if (event.pointerType === "mouse") scheduleShow();
},
onTriggerPointerLeave: (event) => {
if (event.pointerType === "mouse") scheduleHide();
},
onTriggerFocus: () => {
if (ignoreNextFocusRef.current) {
ignoreNextFocusRef.current = false;
return;
}
show("focus");
},
onTriggerBlur: (event) => {
const next = event.relatedTarget as Node | null;
if (next && contentRef.current?.contains(next)) return;
hide();
},
onTriggerClick: (event) => {
// Replace Radix's toggle: open (focus moves in) or, when already open, move focus into the content.
event.preventDefault();
if (openRef.current) focusContent();
else show("click");
},
onContentPointerEnter: (event) => {
if (event.pointerType === "mouse") clearTimer();
},
onContentPointerLeave: (event) => {
if (event.pointerType === "mouse") scheduleHide();
},
onContentFocus: () => {
focusInsideRef.current = true;
},
onContentOpenAutoFocus: (event) => {
// Opened by hover or keyboard focus: keep focus where it is.
if (triggerMode === "hover" && openReasonRef.current !== "click") event.preventDefault();
},
onContentCloseAutoFocus: (event) => {
if (triggerMode !== "hover") return;
if (!focusInsideRef.current) {
// Focus never entered the popover: do not move it.
event.preventDefault();
return;
}
// Radix returns focus to the trigger; that focus must not reopen the popover.
ignoreNextFocusRef.current = true;
setTimeout(() => {
ignoreNextFocusRef.current = false;
}, 0);
},
};
}, [triggerMode, hoverDelay, clearTimer, show, hide]);
return (
<PopoverRootContext.Provider value={context}>
<PopoverPrimitive.Root open={open} onOpenChange={handleOpenChange} modal={modal}>
{children}
</PopoverPrimitive.Root>
</PopoverRootContext.Provider>
);
};
Root.displayName = "Popover.Root";
// ---------------------------------------------------------------------------------------------
// Trigger / Anchor
// ---------------------------------------------------------------------------------------------
const Trigger = React.forwardRef<React.ElementRef<typeof PopoverPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Trigger>>(
({ onPointerEnter, onPointerLeave, onFocus, onBlur, onClick, ...props }, ref) => {
const root = React.useContext(PopoverRootContext);
const hover = root?.triggerMode === "hover" ? root : null;
return (
<PopoverPrimitive.Trigger
ref={ref}
{...props}
onPointerEnter={composeHandlers(onPointerEnter, hover?.onTriggerPointerEnter)}
onPointerLeave={composeHandlers(onPointerLeave, hover?.onTriggerPointerLeave)}
onFocus={composeHandlers(onFocus, hover?.onTriggerFocus)}
onBlur={composeHandlers(onBlur, hover?.onTriggerBlur)}
onClick={composeHandlers(onClick, hover?.onTriggerClick)}
/>
);
}
);
Trigger.displayName = "Popover.Trigger";
const Anchor = PopoverPrimitive.Anchor;
// ---------------------------------------------------------------------------------------------
// Content
// ---------------------------------------------------------------------------------------------
type PopoverPosition = "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right" | "left" | "right";
type Side = "top" | "right" | "bottom" | "left";
type Align = "start" | "center" | "end";
const PLACEMENT_BY_POSITION: Record<PopoverPosition, { side: Side; align: Align }> = {
"top-left": { side: "top", align: "start" },
"top-center": { side: "top", align: "center" },
"top-right": { side: "top", align: "end" },
"bottom-left": { side: "bottom", align: "start" },
"bottom-center": { side: "bottom", align: "center" },
"bottom-right": { side: "bottom", align: "end" },
left: { side: "left", align: "center" },
right: { side: "right", align: "center" },
};
const ANIMATION_CLASSES: Record<"fade" | "scale" | "slide", string> = {
fade: "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
scale: "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
slide: [
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[side=bottom]:data-[state=open]:slide-in-from-top-2 data-[side=bottom]:data-[state=closed]:slide-out-to-top-2",
"data-[side=left]:data-[state=open]:slide-in-from-right-2 data-[side=left]:data-[state=closed]:slide-out-to-right-2",
"data-[side=right]:data-[state=open]:slide-in-from-left-2 data-[side=right]:data-[state=closed]:slide-out-to-left-2",
"data-[side=top]:data-[state=open]:slide-in-from-bottom-2 data-[side=top]:data-[state=closed]:slide-out-to-bottom-2",
].join(" "),
};
export interface ContentProps extends React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> {
showArrow?: boolean;
/** Removes the default surface, padding and typography. */
unstyled?: boolean;
animation?: "fade" | "scale" | "slide";
/** Padding and text size. `small` / `medium` / `large` are deprecated aliases. @default "md" */
size?: PopoverSize | LegacyPopoverSize;
/** Placement shorthand. Explicit `side` / `align` props take precedence. @default "top-center" */
position?: PopoverPosition;
}
/** The popover panel (`role="dialog"`); `className` goes on it. */
const Content = React.forwardRef<React.ElementRef<typeof PopoverPrimitive.Content>, ContentProps>(
(
{
children,
className,
side: sideProp,
align: alignProp,
sideOffset = 12,
collisionPadding = 12,
arrowPadding = 12,
showArrow = true,
unstyled,
animation = "scale",
size,
position = "top-center",
onOpenAutoFocus,
onCloseAutoFocus,
onPointerEnter,
onPointerLeave,
onFocus,
...props
},
forwardedRef
) => {
const root = React.useContext(PopoverRootContext);
const [hasFooter, setHasFooter] = React.useState(false);
const resolvedSize = resolvePopoverSize(size);
const placement = PLACEMENT_BY_POSITION[position];
const hover = root?.triggerMode === "hover" ? root : null;
const ref = React.useCallback(
(node: HTMLDivElement | null) => {
if (root) root.contentRef.current = node;
setRef(forwardedRef, node);
},
[root, forwardedRef]
);
const contextValue = React.useMemo(() => ({ hasFooter, setHasFooter, size: resolvedSize }), [hasFooter, resolvedSize]);
const container = usePortalContainer();
return (
<PopoverContext.Provider value={contextValue}>
<PopoverPrimitive.Portal container={container}>
<PopoverPrimitive.Content
ref={ref}
side={sideProp ?? placement.side}
align={alignProp ?? placement.align}
sideOffset={sideOffset}
collisionPadding={collisionPadding}
arrowPadding={arrowPadding}
onOpenAutoFocus={composeHandlers(onOpenAutoFocus, root?.onContentOpenAutoFocus)}
onCloseAutoFocus={composeHandlers(onCloseAutoFocus, root?.onContentCloseAutoFocus)}
onPointerEnter={composeHandlers(onPointerEnter, hover?.onContentPointerEnter)}
onPointerLeave={composeHandlers(onPointerLeave, hover?.onContentPointerLeave)}
onFocus={composeHandlers(onFocus, root?.onContentFocus)}
className={cn(
"z-popover",
!unstyled && ["w-max rounded-2xl bg-background text-foreground shadow-medium ring-1 ring-border", sizeStyles[resolvedSize].content],
ANIMATION_CLASSES[animation],
// The footer bleeds to the bottom edge.
hasFooter && !unstyled && "pb-0",
className
)}
{...props}
>
<div className={cn(hasFooter && !unstyled && sizeStyles[resolvedSize].bodyWithFooter)}>
<Slottable>{children}</Slottable>
</div>
{showArrow && (
<PopoverPrimitive.Arrow asChild>
<div className="size-[11px] -translate-y-[calc(50%)] -rotate-45 rounded-bl-[3px] border border-border bg-background [clip-path:polygon(0_100%,0_0,100%_100%)]"></div>
</PopoverPrimitive.Arrow>
)}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverContext.Provider>
);
}
);
Content.displayName = "Popover.Content";
// ---------------------------------------------------------------------------------------------
// Close / Footer
// ---------------------------------------------------------------------------------------------
const Close = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Close>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Close> & {
unstyled?: boolean;
}
>(({ className, unstyled, ...props }, forwardedRef) => (
<PopoverPrimitive.Close ref={forwardedRef} className={cn(!unstyled && "absolute right-4 top-4 rounded-lg focus-ring", className)} {...props} />
));
Close.displayName = "Popover.Close";
type FooterProps = React.HTMLAttributes<HTMLDivElement>;
const Footer = React.forwardRef<HTMLDivElement, FooterProps>(({ className, children, ...props }, ref) => {
const context = React.useContext(PopoverContext);
const setHasFooter = context?.setHasFooter;
const size = context?.size ?? "md";
useIsomorphicLayoutEffect(() => {
if (!setHasFooter) return;
setHasFooter(true);
return () => setHasFooter(false);
}, [setHasFooter]);
return (
<div ref={ref} className={cn("flex items-center justify-between gap-3 rounded-b-2xl border-t border-border bg-muted", sizeStyles[size].footer, className)} {...props}>
{children}
</div>
);
});
Footer.displayName = "Popover.Footer";
export { Root, Anchor, Trigger, Content, Close, Footer };
Add the ThemeScope component
Combobox imports ./ThemeScope, so add this file next to it
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/*
* A local light/dark boundary. Everything inside renders in `theme`, whatever the site theme is: the semantic tokens
* (`bg-background`, `text-foreground`, …) and every `dark:` utility resolve to the nearest boundary, not to <html>.
* Overlays that portal out of the tree (Select, Popover, Tooltip, Dropdown, Modal, AlertDialog, Drawer, Toast) render
* into a layer that carries the same theme, so a menu opened inside a dark scope is dark too.
*
* It never touches <html>, the site's theme state or its storage. Without `theme` it renders no theme class and
* follows the surrounding theme. Nest at most one scope below the site theme (see `darkMode` in tailwind.config.ts).
*/
export type ScopeTheme = "light" | "dark";
const PortalContainerContext = React.createContext<HTMLElement | null>(null);
/** Where overlays portal to: the nearest themed ThemeScope's layer, or null for the default (document.body). */
export function usePortalContainer() {
return React.useContext(PortalContainerContext);
}
// A themed layer at the end of <body> for portalled overlays. Only created while the scope sets a theme, so a scope
// that follows the site leaves the document exactly as it was.
function usePortalLayer(theme: ScopeTheme | undefined) {
const [layer, setLayer] = React.useState<HTMLElement | null>(null);
const themed = theme !== undefined;
React.useEffect(() => {
if (!themed) return;
const element = document.createElement("div");
element.setAttribute("data-theme-scope-layer", "");
document.body.appendChild(element);
setLayer(element);
return () => {
element.remove();
setLayer(null);
};
}, [themed]);
React.useEffect(() => {
if (!layer || !theme) return;
// text-foreground: portalled text that sets no colour of its own resolves against the layer's tokens.
layer.className = `${theme} text-foreground`;
}, [layer, theme]);
return layer;
}
export interface RootProps extends React.HTMLAttributes<HTMLDivElement> {
/** Theme for this subtree. Leave undefined to follow the surrounding (site) theme. */
theme?: ScopeTheme;
}
const Root = React.forwardRef<HTMLDivElement, RootProps>(({ theme, className, children, ...props }, ref) => {
const layer = usePortalLayer(theme);
// A scope that follows its surroundings keeps the surrounding scope's layer.
const outerLayer = usePortalContainer();
return (
<div ref={ref} data-theme-scope={theme} className={cn(theme && [theme, "text-foreground"], className)} {...props}>
<PortalContainerContext.Provider value={theme ? layer : outerLayer}>{children}</PortalContainerContext.Provider>
</div>
);
});
Root.displayName = "ThemeScope.Root";
export { Root };
Add the Field component
Combobox imports ./Field, so add this file next to it
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
import * as Label from "./Label";
/*
* Field wires a label, description and message to one form control:
* Field.Label → <label for={controlId}>
* Field.Description → referenced by aria-describedby
* Field.Message → referenced by aria-describedby (error text when invalid)
* invalid / required / disabled → aria-invalid, required/aria-required, disabled on the control
* Controls (Input, Textarea, Select, Checkbox, Switch, Radio) read the context through
* useFieldControlProps. It is plain React state: it works with native forms and form libraries.
*/
type FieldContextValue = {
controlId: string;
labelId: string;
descriptionId: string;
messageId: string;
invalid: boolean;
required: boolean;
disabled: boolean;
hasDescription: boolean;
hasMessage: boolean;
setHasDescription: (value: boolean) => void;
setHasMessage: (value: boolean) => void;
};
const FieldContext = React.createContext<FieldContextValue | null>(null);
export function useField() {
return React.useContext(FieldContext);
}
/**
* Provided by Fieldset.Root. A native <fieldset disabled> already disables the controls inside it;
* this lets each Field.Root reflect that state (label styling, `disabled` on the control).
*/
export const FieldsetContext = React.createContext<{ disabled: boolean } | null>(null);
export interface FieldRootProps extends React.HTMLAttributes<HTMLDivElement> {
/** Id for the control; generated when omitted. */
id?: string;
invalid?: boolean;
required?: boolean;
disabled?: boolean;
}
const Root = React.forwardRef<HTMLDivElement, FieldRootProps>(({ id, invalid = false, required = false, disabled: disabledProp = false, className, children, ...props }, ref) => {
const fieldset = React.useContext(FieldsetContext);
const disabled = disabledProp || Boolean(fieldset?.disabled);
const generatedId = React.useId();
const controlId = id ?? `field-${generatedId}`;
const [hasDescription, setHasDescription] = React.useState(false);
const [hasMessage, setHasMessage] = React.useState(false);
const value = React.useMemo<FieldContextValue>(
() => ({
controlId,
labelId: `${controlId}-label`,
descriptionId: `${controlId}-description`,
messageId: `${controlId}-message`,
invalid,
required,
disabled,
hasDescription,
hasMessage,
setHasDescription,
setHasMessage,
}),
[controlId, invalid, required, disabled, hasDescription, hasMessage]
);
return (
<FieldContext.Provider value={value}>
<div
ref={ref}
className={cn("flex flex-col gap-1.5", className)}
data-invalid={invalid || undefined}
data-disabled={disabled || undefined}
{...props}
>
{children}
</div>
</FieldContext.Provider>
);
});
Root.displayName = "Field.Root";
type FieldLabelProps = React.ComponentPropsWithoutRef<typeof Label.Root>;
const FieldLabel = React.forwardRef<React.ElementRef<typeof Label.Root>, FieldLabelProps>(({ children, ...props }, ref) => {
const field = useField();
return (
<Label.Root ref={ref} id={field?.labelId} htmlFor={field?.controlId} disabled={field?.disabled} {...props}>
{children}
{field?.required && <Label.Asterisk />}
</Label.Root>
);
});
FieldLabel.displayName = "Field.Label";
const Description = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(({ className, ...props }, ref) => {
const field = useField();
const setHasDescription = field?.setHasDescription;
React.useEffect(() => {
if (!setHasDescription) return;
setHasDescription(true);
return () => setHasDescription(false);
}, [setHasDescription]);
return <p ref={ref} id={field?.descriptionId} className={cn("size-p-xs text-muted-foreground", className)} {...props} />;
});
Description.displayName = "Field.Description";
export interface FieldMessageProps extends React.HTMLAttributes<HTMLParagraphElement> {
/** Only render while the field is invalid (default true). */
showWhenValid?: boolean;
}
const Message = React.forwardRef<HTMLParagraphElement, FieldMessageProps>(({ className, showWhenValid = false, children, ...props }, ref) => {
const field = useField();
const visible = Boolean(children) && (showWhenValid || !field || field.invalid);
const setHasMessage = field?.setHasMessage;
React.useEffect(() => {
if (!setHasMessage || !visible) return;
setHasMessage(true);
return () => setHasMessage(false);
}, [setHasMessage, visible]);
if (!visible) return null;
return (
<p
ref={ref}
id={field?.messageId}
aria-live="polite"
className={cn("size-p-xs", field?.invalid ? "text-destructive-strong" : "text-muted-foreground", className)}
{...props}
>
{children}
</p>
);
});
Message.displayName = "Field.Message";
type ControlAttributes = {
id?: string;
"aria-describedby"?: string;
"aria-invalid"?: React.AriaAttributes["aria-invalid"];
"aria-required"?: React.AriaAttributes["aria-required"];
required?: boolean;
disabled?: boolean;
};
/**
* Merges Field context into a control's props. Explicit props win; describedby ids are combined.
*/
export function useFieldControlProps<P extends ControlAttributes>(props: P, options: { invalid?: boolean } = {}): P & ControlAttributes {
const field = useField();
const invalid = Boolean(options.invalid || field?.invalid || props["aria-invalid"] === true || props["aria-invalid"] === "true");
const describedBy = [props["aria-describedby"], field?.hasDescription ? field.descriptionId : undefined, field?.hasMessage ? field.messageId : undefined]
.filter(Boolean)
.join(" ");
return {
...props,
id: props.id ?? field?.controlId,
"aria-describedby": describedBy || undefined,
"aria-invalid": invalid || undefined,
required: props.required ?? (field?.required || undefined),
disabled: props.disabled ?? (field?.disabled || undefined),
};
}
/** Applies Field wiring to any custom control element (single child). */
const Control = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & ControlAttributes>((props, ref) => {
const controlProps = useFieldControlProps(props);
return <Slot ref={ref} {...controlProps} />;
});
Control.displayName = "Field.Control";
export { Root, FieldLabel as Label, Description, Message, Control };
Add the Label component
Combobox imports ./Label, so add this file next to it
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
export interface LabelRootProps extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
/** Dims the label. The control itself must also be disabled. */
disabled?: boolean;
}
const Root = React.forwardRef<React.ElementRef<typeof LabelPrimitive.Root>, LabelRootProps>(({ className, disabled, ...props }, ref) => {
return (
<LabelPrimitive.Root
ref={ref}
data-disabled={disabled || undefined}
className={cn(
"group flex cursor-pointer items-center gap-px size-p-sm text-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:text-muted-foreground/60",
className
)}
{...props}
/>
);
});
Root.displayName = "Label.Root";
// Visual required marker. Screen readers get "required" from the control (required / aria-required).
const Asterisk = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(({ className, children, ...props }, ref) => (
<span ref={ref} aria-hidden="true" className={cn("text-primary-strong group-data-[disabled]:text-muted-foreground/60", className)} {...props}>
{children || "*"}
</span>
));
Asterisk.displayName = "Label.Asterisk";
const Sub = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(({ className, ...props }, ref) => (
<span ref={ref} className={cn("size-p-xs text-muted-foreground group-data-[disabled]:text-muted-foreground/60", className)} {...props} />
));
Sub.displayName = "Label.Sub";
export { Root, Asterisk, Sub };
Create or update your Combobox component
Copy this code to create your Combobox component
"use client";
import * as React from "react";
import { RiArrowDownSLine, RiCheckLine, RiCloseLine } from "react-icons/ri";
import { cn } from "@/lib/utils";
import * as Command from "./Command";
import { useFieldControlProps } from "./Field";
import * as Popover from "./Popover";
/*
* Single-select combobox: a trigger (role="combobox", aria-haspopup="dialog") opens a popover with a
* searchable Command list. The trigger's text is the selected label, so assistive technology reads the value.
* Keyboard: Enter/Space/ArrowDown open; typing a character opens with that search; arrows + Enter pick an option;
* Escape closes and returns focus to the trigger.
* Native forms: with `name`, the value is submitted; `required` blocks submission while empty.
*/
export interface ComboboxOption {
value: string;
label: string;
disabled?: boolean;
/** Extra search terms (e.g. a country code). */
keywords?: string[];
}
export type ComboboxSize = "sm" | "md" | "lg";
const triggerSizes: Record<ComboboxSize, string> = {
sm: "h-8 pl-2.5 pr-2",
md: "h-9 pl-3 pr-2",
lg: "h-10 pl-3 pr-2.5",
};
export interface ComboboxRootProps {
options: ComboboxOption[];
/** Selected value (controlled). `null` means nothing selected. */
value?: string | null;
defaultValue?: string | null;
onValueChange?: (value: string | null) => void;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
placeholder?: string;
searchPlaceholder?: string;
/** Accessible name of the search box inside the popup. */
searchLabel?: string;
emptyMessage?: React.ReactNode;
/** Show a button that clears the selection. */
clearable?: boolean;
clearLabel?: string;
disabled?: boolean;
invalid?: boolean;
required?: boolean;
/** Submitted with native forms. */
name?: string;
form?: string;
size?: ComboboxSize;
id?: string;
"aria-label"?: string;
"aria-labelledby"?: string;
"aria-describedby"?: string;
/** Classes for the outer wrapper (width, margins). */
className?: string;
/** Classes for the popup panel. */
contentClassName?: string;
}
function useControllable<T>(value: T | undefined, defaultValue: T, onChange: ((value: T) => void) | undefined) {
const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
const isControlled = value !== undefined;
const setValue = React.useCallback(
(next: T) => {
if (!isControlled) setUncontrolled(next);
onChange?.(next);
},
[isControlled, onChange]
);
return [isControlled ? (value as T) : uncontrolled, setValue] as const;
}
const Root = React.forwardRef<HTMLButtonElement, ComboboxRootProps>(
(
{
options,
value: valueProp,
defaultValue = null,
onValueChange,
open: openProp,
defaultOpen = false,
onOpenChange,
placeholder = "Select an option",
searchPlaceholder = "Search…",
searchLabel = "Search options",
emptyMessage = "No results found.",
clearable = false,
clearLabel = "Clear selection",
disabled,
invalid,
required,
name,
form,
size = "md",
id,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
"aria-describedby": ariaDescribedBy,
className,
contentClassName,
},
forwardedRef
) => {
const [value, setValue] = useControllable<string | null>(valueProp, defaultValue, onValueChange);
const [open, setOpen] = useControllable<boolean>(openProp, defaultOpen, onOpenChange);
const [search, setSearch] = React.useState("");
const [missingRequired, setMissingRequired] = React.useState(false);
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
const contentId = `${React.useId()}-listbox-dialog`;
const controlProps = useFieldControlProps({ id, "aria-describedby": ariaDescribedBy, disabled, required }, { invalid: invalid || (missingRequired && !value) });
const isInvalid = Boolean(controlProps["aria-invalid"]);
const isDisabled = Boolean(controlProps.disabled);
const isRequired = Boolean(controlProps.required);
const selected = options.find((option) => option.value === value) ?? null;
const setTriggerRef = React.useCallback(
(node: HTMLButtonElement | null) => {
triggerRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
},
[forwardedRef]
);
const handleOpenChange = (next: boolean) => {
if (isDisabled && next) return;
setOpen(next);
if (!next) setSearch("");
};
const select = (next: string) => {
setValue(next);
setMissingRequired(false);
handleOpenChange(false);
};
const clear = () => {
setValue(null);
triggerRef.current?.focus();
};
const onTriggerKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (open || isDisabled) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
handleOpenChange(true);
} else if (event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey) {
// Type-to-search: open with the typed character already in the search box.
event.preventDefault();
setSearch(event.key);
handleOpenChange(true);
}
};
const showClear = clearable && selected !== null && !isDisabled;
return (
<div className={cn("relative w-full", className)}>
<Popover.Root open={open} onOpenChange={handleOpenChange}>
<Popover.Trigger asChild>
<button
ref={setTriggerRef}
type="button"
role="combobox"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={open ? contentId : undefined}
id={controlProps.id}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={controlProps["aria-describedby"]}
aria-invalid={isInvalid || undefined}
aria-required={isRequired || undefined}
disabled={isDisabled}
data-placeholder={selected ? undefined : ""}
onKeyDown={onTriggerKeyDown}
className={cn(
"group flex w-full items-center justify-between gap-2 rounded-lg border bg-transparent text-left size-p-sm text-foreground transition duration-base ease-standard field-focus",
triggerSizes[size],
showClear && "pr-14",
isInvalid
? "border-destructive outline-destructive/25 hover:bg-destructive-subtle"
: "border-input outline-accent hover:bg-muted data-[state=open]:border-foreground focus:border-foreground",
"data-[placeholder]:text-muted-foreground",
"disabled:cursor-not-allowed disabled:border-transparent disabled:bg-muted disabled:text-muted-foreground"
)}
>
<span className="truncate">{selected ? selected.label : placeholder}</span>
<RiArrowDownSLine
aria-hidden="true"
className="ml-auto size-5 shrink-0 text-muted-foreground transition-transform duration-base group-data-[state=open]:rotate-180"
/>
</button>
</Popover.Trigger>
<Popover.Content
id={contentId}
unstyled
showArrow={false}
position="bottom-left"
sideOffset={6}
animation="slide"
className={cn(
"w-[var(--radix-popover-trigger-width)] min-w-48 max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border bg-surface text-surface-foreground shadow-medium",
contentClassName
)}
>
<Command.Root label={searchLabel} defaultValue={value ?? undefined} loop>
<Command.Input placeholder={searchPlaceholder} value={search} onValueChange={setSearch} />
<Command.List className="max-h-64">
<Command.Empty>{emptyMessage}</Command.Empty>
{options.map((option) => {
const isSelected = option.value === value;
return (
<Command.Item
key={option.value}
value={option.value}
keywords={[option.label, ...(option.keywords ?? [])]}
disabled={option.disabled}
data-checked={isSelected || undefined}
onSelect={() => select(option.value)}
className="pr-8"
>
<span className="truncate">{option.label}</span>
{isSelected && (
<>
<span className="sr-only">, selected</span>
<RiCheckLine aria-hidden="true" className="absolute right-2 text-foreground" />
</>
)}
</Command.Item>
);
})}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
{showClear && (
<button
type="button"
aria-label={clearLabel}
onClick={clear}
className="absolute right-8 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors duration-fast hover:bg-muted hover:text-foreground focus-ring"
>
<RiCloseLine aria-hidden="true" className="size-4" />
</button>
)}
{(name || isRequired) && (
// Carries the value for native forms and blocks submission while a required value is missing.
<input
tabIndex={-1}
aria-hidden="true"
name={name}
form={form}
value={value ?? ""}
required={isRequired}
disabled={isDisabled}
onChange={() => {}}
onInvalid={(event) => {
event.preventDefault();
setMissingRequired(true);
triggerRef.current?.focus();
}}
className="pointer-events-none absolute bottom-0 left-0 size-px opacity-0"
/>
)}
</div>
);
}
);
Root.displayName = "Combobox.Root";
export { Root };
Test Your Component
- 1. Add the component to a test page
- 2. Check all props and variants work as expected
- 3. Verify responsive behavior
- 4. Test with different content/data
- 5. Ensure accessibility features work correctly
Searchable
Search matches the label and any keywords (here country codes and local names). Controlled with value and onValueChange; null means nothing selected.
Selected value: ma
Disabled Options
Disabled options stay visible but are skipped by the keyboard and cannot be chosen.
Unavailable regions can be seen but not chosen.
Clearable and Disabled
clearable adds a labelled clear button next to the value. A disabled Field disables the combobox.
Field Validation
Inside Field.Root the combobox gets the label, description, message, invalid and required state. With name it submits its value in a native form.
Combobox vs Select
- Select: a short, fixed list (up to ~10 options) people can scan.
- Combobox: long lists, or lists people search by name or code. It is single-select; a multi-select is not part of CK-UI yet.
Accessibility
- The trigger is a
role="combobox"button witharia-haspopup="dialog",aria-expandedandaria-controls. Its name comes from Field.Label and its value is the selected label. - Enter, Space or ArrowDown opens it; typing a letter opens it with that search. Inside, arrows move through options, Enter selects, Escape closes. Focus returns to the trigger.
- The selected option shows a check and is announced as "selected". Disabled options are skipped.
- Field wiring:
aria-describedby(description and message),aria-invalid,aria-required. A required, empty combobox blocks native form submission and receives focus. - The clear button is a separate button named "Clear selection" (configurable); focus returns to the trigger.
API Reference
Import with import * as Combobox from "@/components/ui/Combobox". No router or Next.js dependency.
Combobox.Root
The whole control. ref points at the trigger button.
| Prop | Type | Default | Description |
|---|---|---|---|
options | "{ value, label, disabled?, keywords? }[]" | "-" | Required. The choices. |
value / defaultValue | "string | null" | "null" | Selected value (controlled / uncontrolled). |
onValueChange | "(value: string | null) => void" | "undefined" | Called on select and clear. |
placeholder | "string" | "Select an option" | Trigger text when empty. |
searchPlaceholder / searchLabel | "string" | "Search… / Search options" | Search box text and accessible name. |
emptyMessage | "ReactNode" | "No results found." | Shown when nothing matches. |
clearable / clearLabel | "boolean / string" | "false / Clear selection" | Clear button. |
disabled / invalid / required | "boolean" | "from Field" | State; also read from Field.Root. |
name / form | "string" | "undefined" | Native form submission. |
open / defaultOpen / onOpenChange | "boolean" | "false" | Popup state. |
size | "sm""md""lg" | "md" | Trigger height. |
className | "string" | "undefined" | Classes for the wrapper. |
contentClassName | "string" | "undefined" | Classes for the popup. |
Used in these blocks
Block families with at least one block built on this component. Each page previews its blocks and lists the components and packages they use.