Forms
Dropdown
Dropdown combines a popover with a filterable command list. Single-select filtering types in the trigger; multi-select adds a panel filter, checkboxes, Select All, and optional custom option add/edit.
Installation
npx shadcn add https://rck.vibeboxph.com//r/dropdown.jsonLive preview & controls
Controls
"use client";
import { Dropdown } from "@/registry/rck/ui/dropdown";
const fruitOptions = [
{ value: "apple", label: "Apple" },
{ value: "banana", label: "Banana" },
{ value: "cherry", label: "Cherry" },
{ value: "durian", label: "Durian" },
];
export default function DropdownDemo() {
return (
<Dropdown
options={fruitOptions}
placeholder="Select a fruit…"
searchPlaceholder="Filter fruits…"
/>
);
}Anatomy
dropdown-triggerButton or combobox input showing the current selection.dropdown-contentThe popover panel with filter input, Select All, and options.CommandItemEach selectable option with checkbox (multi) or check icon (single).
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| options* | { value: string; label: string; disabled?: boolean }[] | — | The list of selectable options. |
| value / defaultValue | string | string[] | — | Controlled / uncontrolled selection (array when `multiple`). |
| onValueChange | (value: string | string[], option?: DropdownOption | DropdownOption[] | null) => void | — | Fires on selection change with resolved option object(s). |
| multiple | boolean | false | Allow selecting more than one option. |
| filter / searchable | boolean | false | Enables filtering — single mode filters via trigger input; multi mode shows a panel filter. |
| filterAtBeginning | boolean | false | Match options by prefix instead of contains. |
| onFilterChange | (text: string) => void | — | Fires when the filter text changes. |
| clearable | boolean | — | Shows a clear-selection affordance in the trigger. |
| loading | boolean | — | Shows a spinner and disables interaction. |
| hideOnScroll | boolean | — | Closes the menu when a scroll parent moves. |
| dropdownHeight / dropdownWidth | number | string | — | Override list max-height or menu width. |
| placeholder / searchPlaceholder | string | — | Copy for the trigger and search field. |
| emptyText | string | No matching options. | Copy shown when no options match the current filter. |
| fullWidth | boolean | true | Stretch the trigger and menu to the container width. |
| size | "sm" | "md" | "lg" | md | Trigger height and text scale. |
| disabled | boolean | — | Disables the trigger. |
| showDisabledIcon | boolean | — | Shows a lock icon when disabled. |
| onClear | () => void | — | Fires after the selection is cleared. |
| className | string | — | Merged onto the combobox trigger. |
| contentClassName | string | — | Merged onto the popover content panel. |
| onOpenChange | (open: boolean) => void | — | Fires when the popover opens or closes. |
Accessibility
- Trigger uses `role="combobox"` with `aria-expanded` and `aria-controls` pointing at the listbox.
- Single-select filter mode uses an input combobox; multi-select keeps a button trigger with a panel filter.
- Arrow Up/Down opens and navigates the menu; Enter selects; Escape closes.
- Select All exposes a checkbox row; multi options include visible checkboxes.
- Selected options use check icons or checkboxes, not color alone.
Source
registry/rck/ui/dropdown.tsx
export { Dropdown, type DropdownOption, type DropdownProps } from "./dropdown/dropdown";
registry/rck/ui/dropdown/dropdown.tsx
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@/registry/rck/primitives/popover";
import { cn } from "@/lib/utils";
import { useControllableState } from "@/hooks/use-controllable-state";
import { DropdownContent } from "./dropdown-content";
import { DropdownButtonTrigger, DropdownInputTrigger } from "./dropdown-trigger";
import { getScrollParent } from "./get-scroll-parent";
import { resolveOptions } from "./use-combined-options";
import { commandMatches } from "@/registry/rck/primitives/command";
import type { DropdownChangeOptionArg, DropdownProps } from "./types";
export type { DropdownOption, DropdownProps } from "./types";
export const Dropdown = React.forwardRef<HTMLButtonElement, DropdownProps>(
(
{
options,
value,
defaultValue,
onValueChange,
onOpenChange,
multiple = false,
searchable = false,
filter,
filterAtBeginning = false,
onFilterChange,
placeholder = "Select…",
searchPlaceholder = "Search…",
emptyText = "No matching options.",
disabled = false,
clearable = false,
loading = false,
hideOnScroll = false,
dropdownHeight,
dropdownWidth,
showDisabledIcon = false,
onClear,
size = "md",
fullWidth = true,
className,
contentClassName,
id,
onKeyDown,
onBlur,
onFocus,
...triggerProps
},
ref,
) => {
const filterEnabled = filter ?? searchable;
const useInputTrigger = filterEnabled && !multiple;
const filterInPanel = filterEnabled && multiple;
const [open, setOpen] = React.useState(false);
const [filterText, setFilterText] = React.useState("");
const [hasTyped, setHasTyped] = React.useState(false);
const [highlightedValue, setHighlightedValue] = React.useState<string>();
const listboxId = React.useId();
const commandRef = React.useRef<HTMLDivElement>(null);
const inputTriggerRef = React.useRef<HTMLInputElement>(null);
const buttonTriggerRef = React.useRef<HTMLButtonElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const filterMode = filterAtBeginning ? "startsWith" : "contains";
const visibleOptions = React.useMemo(
() =>
options.filter(
(option) =>
!option.disabled &&
(!filterEnabled || commandMatches(option.label, filterText, filterMode)),
),
[filterEnabled, filterMode, filterText, options],
);
function getOptionId(optionValue: string) {
return `${listboxId}-option-${encodeURIComponent(optionValue)}`;
}
function setInitialHighlight() {
const selectedOption = options.find(
(option) => selectedValues.includes(option.value) && !option.disabled,
);
setHighlightedValue(selectedOption?.value ?? visibleOptions[0]?.value);
}
function moveHighlight(direction: "next" | "previous") {
if (!open) {
handleOpenChange(true);
return;
}
if (visibleOptions.length === 0) {
setHighlightedValue(undefined);
return;
}
const currentIndex = highlightedValue
? visibleOptions.findIndex((option) => option.value === highlightedValue)
: -1;
const nextIndex =
direction === "next"
? (currentIndex + 1) % visibleOptions.length
: (currentIndex - 1 + visibleOptions.length) % visibleOptions.length;
setHighlightedValue(visibleOptions[nextIndex].value);
}
function selectHighlighted() {
if (!open) {
handleOpenChange(true);
return;
}
const option = visibleOptions.find((item) => item.value === highlightedValue);
if (option) toggleValue(option.value);
}
const [internalValue, setInternalValue] = useControllableState<string | string[]>({
prop: value,
defaultProp: defaultValue ?? (multiple ? [] : ""),
});
const selectedValues = React.useMemo<string[]>(
() =>
Array.isArray(internalValue)
? internalValue
: internalValue
? [internalValue]
: [],
[internalValue],
);
function emitChange(nextValue: string | string[]) {
const optionArg: DropdownChangeOptionArg = multiple
? resolveOptions(options, Array.isArray(nextValue) ? nextValue : [])
: typeof nextValue === "string" && nextValue
? (options.find((option) => option.value === nextValue) ?? null)
: null;
setInternalValue(nextValue);
onValueChange?.(nextValue, optionArg);
}
function handleOpenChange(nextOpen: boolean) {
setOpen(nextOpen);
onOpenChange?.(nextOpen);
if (!nextOpen) {
setFilterText("");
setHasTyped(false);
} else if (useInputTrigger && !hasTyped) {
const selected = options.find((option) => option.value === selectedValues[0]);
setFilterText(selected?.label ?? "");
}
if (nextOpen) setInitialHighlight();
}
function handleFilterTextChange(text: string) {
setFilterText(text);
setHasTyped(true);
onFilterChange?.(text);
setHighlightedValue(
options.find(
(option) => !option.disabled && commandMatches(option.label, text, filterMode),
)?.value,
);
}
function toggleValue(optionValue: string) {
if (multiple) {
const next = selectedValues.includes(optionValue)
? selectedValues.filter((value) => value !== optionValue)
: [...selectedValues, optionValue];
emitChange(next);
} else {
emitChange(optionValue);
handleOpenChange(false);
setFilterText(
options.find((option) => option.value === optionValue)?.label ?? "",
);
setHasTyped(false);
}
}
function handleSelectAll() {
const enabled = options.filter((option) => !option.disabled);
const allSelected = enabled.every((option) =>
selectedValues.includes(option.value),
);
const next = allSelected ? [] : enabled.map((option) => option.value);
emitChange(next);
}
function handleClear(event: React.MouseEvent) {
event.stopPropagation();
emitChange(multiple ? [] : "");
onClear?.();
setFilterText("");
setHasTyped(false);
if (!multiple) handleOpenChange(true);
}
function handleButtonTriggerKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
if (!disabled && !loading) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
moveHighlight(event.key === "ArrowDown" ? "next" : "previous");
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
selectHighlighted();
}
}
onKeyDown?.(event);
}
function handleInputTriggerKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (!disabled && !loading) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
moveHighlight(event.key === "ArrowDown" ? "next" : "previous");
} else if (event.key === "Enter") {
event.preventDefault();
selectHighlighted();
} else if (event.key === "Escape") {
handleOpenChange(false);
}
}
onKeyDown?.(event as unknown as React.KeyboardEvent<HTMLButtonElement>);
}
const selectedLabels = options
.filter((option) => selectedValues.includes(option.value))
.map((option) => option.label);
const triggerLabel =
selectedLabels.length === 0
? placeholder
: multiple
? `${selectedLabels.length} selected ${selectedLabels.length === 1 ? "item" : "items"}`
: selectedLabels[0];
const inputValue =
open && useInputTrigger
? hasTyped
? filterText
: triggerLabel === placeholder
? ""
: triggerLabel
: triggerLabel === placeholder
? ""
: triggerLabel;
const highlightedOption = options.find((option) => option.value === highlightedValue);
React.useEffect(() => {
if (!open) return;
const frame = requestAnimationFrame(() => {
if (useInputTrigger) inputTriggerRef.current?.focus();
else if (filterInPanel) {
commandRef.current
?.querySelector<HTMLInputElement>("[cmdk-input]:not(.sr-only)")
?.focus();
}
});
return () => cancelAnimationFrame(frame);
}, [open, useInputTrigger, filterInPanel]);
React.useEffect(() => {
if (!open || visibleOptions.length === 0) return;
if (!visibleOptions.some((option) => option.value === highlightedValue)) {
setHighlightedValue(visibleOptions[0].value);
}
}, [highlightedValue, open, visibleOptions]);
React.useEffect(() => {
if (!open || !hideOnScroll) return;
const anchor =
containerRef.current ?? inputTriggerRef.current ?? buttonTriggerRef.current;
const scrollParent = getScrollParent(anchor as HTMLElement | null);
const onScroll = () => handleOpenChange(false);
scrollParent.addEventListener("scroll", onScroll, { passive: true });
return () => scrollParent.removeEventListener("scroll", onScroll);
}, [open, hideOnScroll]);
const contentStyle: React.CSSProperties = {
...(fullWidth
? {
width: dropdownWidth ?? "var(--radix-popover-trigger-width)",
minWidth: dropdownWidth ?? "var(--radix-popover-trigger-width)",
boxSizing: "border-box",
}
: dropdownWidth
? { width: dropdownWidth, minWidth: dropdownWidth, boxSizing: "border-box" }
: {}),
};
return (
<div ref={containerRef} className={cn(fullWidth && "w-full")}>
<PopoverPrimitive.Root
open={open}
onOpenChange={handleOpenChange}
anchorRef={containerRef}
>
<PopoverPrimitive.Trigger asChild>
{useInputTrigger ? (
<DropdownInputTrigger
ref={inputTriggerRef}
id={id}
value={inputValue}
placeholder={open ? searchPlaceholder : placeholder}
disabled={disabled}
loading={loading}
readOnly={!open}
open={open}
listboxId={listboxId}
activeDescendant={
highlightedOption ? getOptionId(highlightedOption.value) : undefined
}
fullWidth={fullWidth}
size={size}
clearable={clearable}
canClear={selectedValues.length > 0}
showDisabledIcon={showDisabledIcon}
onClear={handleClear}
onFocus={onFocus as React.FocusEventHandler<HTMLInputElement> | undefined}
onBlur={onBlur as React.FocusEventHandler<HTMLInputElement> | undefined}
onClick={
(() =>
handleOpenChange(true)) as React.MouseEventHandler<HTMLInputElement>
}
onChange={(event) => {
handleFilterTextChange(event.target.value);
if (!open) handleOpenChange(true);
}}
onKeyDown={handleInputTriggerKeyDown}
className={className}
{...(triggerProps as unknown as Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"size"
>)}
/>
) : (
<DropdownButtonTrigger
ref={(node) => {
buttonTriggerRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}}
id={id}
disabled={disabled}
loading={loading}
open={open}
triggerLabel={triggerLabel}
placeholder={selectedLabels.length === 0}
fullWidth={fullWidth}
size={size}
clearable={clearable}
canClear={selectedValues.length > 0}
showDisabledIcon={showDisabledIcon}
onClear={handleClear}
listboxId={listboxId}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={handleButtonTriggerKeyDown}
className={className}
activeDescendant={
highlightedOption ? getOptionId(highlightedOption.value) : undefined
}
{...triggerProps}
/>
)}
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
role="presentation"
data-slot="dropdown-content"
align="start"
sideOffset={6}
onOpenAutoFocus={(event) => event.preventDefault()}
onPointerDown={(event) => event.preventDefault()}
style={contentStyle}
className={cn(
"border-border bg-popover text-popover-foreground z-50 overflow-hidden rounded-lg border p-0 shadow-md outline-none",
"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",
contentClassName,
)}
>
<DropdownContent
commandRef={commandRef}
listboxId={listboxId}
options={options}
selectedValues={selectedValues}
multiple={multiple}
filterEnabled={filterEnabled}
filterInPanel={filterInPanel}
filterAtBeginning={filterAtBeginning}
filterText={filterText}
onFilterTextChange={handleFilterTextChange}
searchPlaceholder={searchPlaceholder}
emptyText={emptyText}
dropdownHeight={dropdownHeight}
highlightedValue={highlightedValue}
onHighlightChange={setHighlightedValue}
getOptionId={getOptionId}
onToggleValue={toggleValue}
onSelectAll={handleSelectAll}
/>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
</div>
);
},
);
Dropdown.displayName = "Dropdown";
hooks/use-controllable-state.ts
import * as React from "react";
export interface UseControllableStateProps<T> {
prop?: T;
defaultProp?: T;
onChange?: (value: T) => void;
}
/**
* Shared controlled/uncontrolled state pattern used across interactive
* components (Accordion, Tabs, Dropdown, DatePicker, Modal, Panel, Alert).
* Pass `value`/`onValueChange` to control the component; omit `value` (use
* `defaultValue`) to let it manage its own state.
*/
export function useControllableState<T>({
prop,
defaultProp,
onChange,
}: UseControllableStateProps<T>): [T, (next: T | ((prev: T) => T)) => void] {
const [uncontrolledState, setUncontrolledState] = React.useState<T | undefined>(
defaultProp,
);
const isControlled = prop !== undefined;
const value = isControlled ? prop : uncontrolledState;
const onChangeRef = React.useRef(onChange);
React.useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
const setValue = React.useCallback(
(next: T | ((prev: T) => T)) => {
if (isControlled) {
const resolved =
typeof next === "function" ? (next as (prev: T) => T)(prop as T) : next;
if (resolved !== prop) {
onChangeRef.current?.(resolved);
}
} else {
setUncontrolledState((prev) => {
const resolved =
typeof next === "function" ? (next as (prev: T) => T)(prev as T) : next;
if (resolved !== prev) {
queueMicrotask(() => onChangeRef.current?.(resolved));
}
return resolved;
});
}
},
[isControlled, prop],
);
return [value as T, setValue];
}
Related examples
Single selectBasic single-selection dropdown.
Multiple selectMulti-select with a clear button.
SearchableFilter a long option list by typing.
Filter in triggerSingle-select combobox with beginning-of-label filtering and highlighted matches.
Filter + Select AllMulti-select with panel filter and Select All row.
ControlledValue driven from external buttons.