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.json

Live 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

PropTypeDefaultDescription
options*{ value: string; label: string; disabled?: boolean }[]The list of selectable options.
value / defaultValuestring | string[]Controlled / uncontrolled selection (array when `multiple`).
onValueChange(value: string | string[], option?: DropdownOption | DropdownOption[] | null) => voidFires on selection change with resolved option object(s).
multiplebooleanfalseAllow selecting more than one option.
filter / searchablebooleanfalseEnables filtering — single mode filters via trigger input; multi mode shows a panel filter.
filterAtBeginningbooleanfalseMatch options by prefix instead of contains.
onFilterChange(text: string) => voidFires when the filter text changes.
clearablebooleanShows a clear-selection affordance in the trigger.
loadingbooleanShows a spinner and disables interaction.
hideOnScrollbooleanCloses the menu when a scroll parent moves.
dropdownHeight / dropdownWidthnumber | stringOverride list max-height or menu width.
placeholder / searchPlaceholderstringCopy for the trigger and search field.
emptyTextstringNo matching options.Copy shown when no options match the current filter.
fullWidthbooleantrueStretch the trigger and menu to the container width.
size"sm" | "md" | "lg"mdTrigger height and text scale.
disabledbooleanDisables the trigger.
showDisabledIconbooleanShows a lock icon when disabled.
onClear() => voidFires after the selection is cleared.
classNamestringMerged onto the combobox trigger.
contentClassNamestringMerged onto the popover content panel.
onOpenChange(open: boolean) => voidFires 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

Browse the full examples gallery