Octocat

Dropdown

A floating menu that displays a list of options.

import { Button } from '@/components/button';
import {
  Dropdown,
  DropdownItem,
  DropdownItems,
  DropdownTrigger,
} from '@/components/dropdown';

export default function DropdownPreview() {
  return (
    <Dropdown>
      <DropdownTrigger asChild>
        <Button variant="outline">Open Menu</Button>
      </DropdownTrigger>
      <DropdownItems>
        <DropdownItem>Edit</DropdownItem>
        <DropdownItem>Duplicate</DropdownItem>
        <DropdownItem>Archive</DropdownItem>
        <DropdownItem disabled>Delete</DropdownItem>
      </DropdownItems>
    </Dropdown>
  );
}

Dependencies

Source Code

'use client';

import {
  FloatingList,
  type UseInteractionsReturn,
  useClick,
  useDismiss,
  useInteractions,
  useListItem,
  useListNavigation,
  useMergeRefs,
  useRole,
  useTypeahead,
} from '@floating-ui/react';
import {
  createContext,
  use,
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from 'react';

import { Slot } from '@/components/slot';
import { useStableCallback } from '@/foundations/hooks/use-stable-callback';
import { Divider } from '@/components/divider';
import {
  type Popover,
  PopoverContent,
  PopoverContext,
  PopoverEmpty,
  PopoverSearchInput,
  PopoverTrigger,
  usePopoverContext,
  usePopoverFloating,
} from '@/components/popover';
import { cn } from '@/lib/utils/classnames';

type Item = {
  id: string;
  label: string;
  onSelect?: (e: { preventDefault: () => void }) => void;
};

type Items = Record<string, Item>;

interface DropdownContextType {
  elementsRef: React.RefObject<(HTMLElement | null)[]>;
  labelsRef: React.RefObject<string[]>;
  highlightedIndex: number | null;
  setHighlightedIndex: (index: number | null) => void;
  searchInputRef: HTMLInputElement | null;
  setSearchInputRef: (el: HTMLInputElement) => void;
  getItemProps: UseInteractionsReturn['getItemProps'];
  items: Items;
  registerItem: (item: Item) => () => void;
}

const DropdownContext = createContext<DropdownContextType | null>(null);

const useInternalDropdownContext = () => {
  const context = use(DropdownContext);

  if (context == null) {
    throw new Error('Dropdown components must be wrapped in <Dropdown />');
  }

  return context;
};

const Dropdown = ({
  children,
  modal = true,
  placement = 'bottom-start',
  ...props
}: React.ComponentProps<typeof Popover>) => {
  const floating = usePopoverFloating({ placement, ...props });

  const [highlightedIndex, setHighlightedIndex] = useState<number | null>(null);
  const elementsRef = useRef<(HTMLElement | null)[]>([]);
  const [items, setItems] = useState<Items>({});
  const labelsRef = useRef<string[]>([]);

  const [searchInputRef, setSearchInputRef] = useState<HTMLInputElement | null>(
    null
  );

  useEffect(() => {
    labelsRef.current = Object.values(items).map((item) => item.label);
  }, [items]);

  const registerItem = useCallback((item: Item) => {
    setItems((prev) => ({ ...prev, [item.id]: item }));

    return () => {
      setItems((prev) => {
        delete prev[item.id];
        return prev;
      });
    };
  }, []);

  const click = useClick(floating.context);
  const dismiss = useDismiss(floating.context);
  const role = useRole(floating.context);

  const listNav = useListNavigation(floating.context, {
    listRef: elementsRef,
    activeIndex: highlightedIndex,
    onNavigate: setHighlightedIndex,
    virtual: !!searchInputRef || undefined,
  });

  const typeahead = useTypeahead(floating.context, {
    enabled: !searchInputRef,
    listRef: labelsRef,
    activeIndex: highlightedIndex,
    onMatch: setHighlightedIndex,
  });

  const interactions = useInteractions([
    click,
    dismiss,
    role,
    listNav,
    typeahead,
  ]);

  const popoverContextValue = useMemo(
    () => ({
      ...floating,
      ...interactions,
      modal,
    }),
    [floating, interactions, modal]
  );

  const menuContextValue = useMemo(
    () => ({
      elementsRef,
      labelsRef,
      highlightedIndex,
      setHighlightedIndex,
      searchInputRef,
      setSearchInputRef,
      getItemProps: interactions.getItemProps,
      registerItem,
      items,
    }),
    [highlightedIndex, searchInputRef, interactions, registerItem, items]
  );

  return (
    <PopoverContext value={popoverContextValue}>
      <DropdownContext value={menuContextValue}>{children}</DropdownContext>
    </PopoverContext>
  );
};

const DropdownTrigger = PopoverTrigger;

const DropdownItems = ({
  children,
  className,
  ...props
}: React.ComponentProps<typeof PopoverContent>) => {
  const { elementsRef } = useInternalDropdownContext();

  return (
    <PopoverContent className={cn('p-0', className)} {...props}>
      <FloatingList elementsRef={elementsRef}>{children}</FloatingList>
    </PopoverContent>
  );
};

interface DropdownItemProps extends React.ComponentPropsWithRef<'button'> {
  onSelect?: Item['onSelect'];
  asChild?: boolean;
}

const DropdownItem = ({
  ref: refProp,
  children,
  className,
  disabled,
  onClick,
  onSelect,
  onKeyDown,
  asChild,
  ...props
}: DropdownItemProps) => {
  const itemId = useId();
  const innerRef = useRef<HTMLButtonElement | null>(null);
  const {
    registerItem,
    highlightedIndex,
    getItemProps,
    items,
    searchInputRef,
    elementsRef,
  } = useInternalDropdownContext();
  const popoverCtx = usePopoverContext();
  const stableOnSelect = useStableCallback(onSelect);

  const { ref: listItemRef, index } = useListItem();

  const ref = useMergeRefs([listItemRef, refProp, innerRef]);

  const isHighlighted = highlightedIndex === index;

  const Comp = asChild ? Slot : 'button';

  useLayoutEffect(() => {
    const text = innerRef.current?.textContent;

    if (!text) return;

    const unregister = registerItem({
      id: itemId,
      label: text,
      onSelect: stableOnSelect,
    });

    return unregister;
  }, [registerItem, itemId, stableOnSelect]);

  const handleClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
    stableOnSelect?.(e);
    onClick?.(e);

    if (!e.defaultPrevented) {
      popoverCtx.setOpen(false);
    }
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
    if (e.key === 'Enter' && highlightedIndex !== null) {
      // we are using elementsRef to preserve DOM order
      // certain items can be unmounted and registered again in a wrong position
      // elementsRef preserves the correct order
      const id = elementsRef.current[highlightedIndex]?.dataset.itemId;
      if (id) items[id]?.onSelect?.(e);
    } else if (searchInputRef) {
      searchInputRef.focus();
    }

    onKeyDown?.(e);
  };

  return (
    <Comp
      ref={ref}
      data-item-id={itemId}
      data-highlighted={isHighlighted || undefined}
      tabIndex={isHighlighted ? 0 : -1}
      disabled={disabled || undefined}
      data-disabled={disabled || undefined}
      className={cn(
        'relative mx-1 flex w-[calc(100%-calc(var(--spacing)*2))] cursor-pointer select-none items-center gap-1.5 rounded-lg px-3 py-1.5 font-medium text-base text-foreground/80 outline-none first-of-type:mt-1 last-of-type:mb-1 data-disabled:pointer-events-none data-highlighted:bg-background-secondary data-disabled:opacity-50',
        className
      )}
      {...getItemProps({
        ...props,
        onKeyDown: handleKeyDown,
        onClick: handleClick,
      })}
    >
      {children}
    </Comp>
  );
};

interface DropdownSectionContextType {
  setTitleId: (id: string) => void;
}

const DropdownSectionContext = createContext<DropdownSectionContextType | null>(
  null
);

const DropdownSection = ({
  children,
  className,
  ...props
}: React.ComponentPropsWithRef<'div'>) => {
  const [titleId, setTitleId] = useState<string | undefined>(undefined);

  return (
    <DropdownSectionContext value={{ setTitleId }}>
      {/** biome-ignore lint/a11y/useSemanticElements: maintain div */}
      <div
        role="group"
        aria-labelledby={titleId}
        className={cn(
          'flex flex-col items-stretch border-border not-first:border-t',
          className
        )}
        {...props}
      >
        {children}
      </div>
    </DropdownSectionContext>
  );
};

const DropdownHeading = ({
  children,
  id: propsId,
  className,
  ...props
}: React.ComponentPropsWithRef<'div'>) => {
  const generatedId = useId();
  const id = propsId ?? generatedId;
  const ctx = use(DropdownSectionContext);

  useLayoutEffect(() => {
    if (ctx) ctx.setTitleId(id);
  }, [ctx, id]);

  return (
    <div
      className={cn(
        'px-3.5 pt-3 pb-1 font-medium text-foreground-secondary text-sm',
        className
      )}
      {...props}
    >
      {children}
    </div>
  );
};

const DropdownDivider = ({
  className,
  ...props
}: React.ComponentPropsWithRef<'div'>) => {
  return <Divider className={cn('my-1', className)} {...props} />;
};

const DropdownSearchInput = ({
  ref: refProp,
  onChange,
  onKeyDown,
  ...props
}: React.ComponentPropsWithRef<'input'>) => {
  const internalRef = useRef<HTMLInputElement | null>(null);
  const {
    highlightedIndex,
    setHighlightedIndex,
    items,
    setSearchInputRef,
    elementsRef,
  } = useInternalDropdownContext();

  const ref = useMergeRefs([refProp, internalRef]);

  useEffect(() => {
    if (internalRef.current) {
      setSearchInputRef(internalRef.current);
    }
  }, [setSearchInputRef]);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    onChange?.(e);

    setHighlightedIndex(0);
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter' && highlightedIndex !== null) {
      const id = elementsRef.current[highlightedIndex]?.dataset.itemId;
      if (id) items[id]?.onSelect?.(e);
    }

    onKeyDown?.(e);
  };

  return (
    <PopoverSearchInput
      ref={ref}
      onChange={handleChange}
      onKeyDown={handleKeyDown}
      {...props}
    />
  );
};

const DropdownEmpty = PopoverEmpty;

const useDropdownContext = usePopoverContext;

export {
  Dropdown,
  DropdownDivider,
  DropdownEmpty,
  DropdownHeading,
  DropdownItem,
  DropdownItems,
  DropdownSearchInput,
  DropdownSection,
  DropdownTrigger,
  useDropdownContext,
};

Features

  • Smart Positioning: Automatically adjusts position to stay in view
  • Focus Management: Traps focus within the menu when opened
  • Section Support: Group related items with optional headings
  • Search: Built-in search input for filtering items
  • Multiple Selection: Support for selecting multiple items
  • Custom Items: Flexible API for custom item rendering

Anatomy


          <Dropdown>
  <DropdownTrigger />
  <DropdownItems>
    <DropdownSection>
      <DropdownHeading />
      <DropdownItem />
      <DropdownDivider />
      <DropdownItem />
    </DropdownSection>
    <DropdownSearchInput />
    <DropdownEmpty />
  </DropdownItems>
</Dropdown>
        

API Reference

Extends the Popover component.

Prop Default Type Description
modal true boolean Whether to trap focus inside the dropdown.
placement "bottom-start" Placement The placement of the dropdown relative to its trigger.
onOpenChange - (open: boolean) => void Callback fired when the dropdown opens or closes.

Extends the PopoverTrigger component.

Extends the PopoverContent component.

The content will be rendered in a portal and will be positioned relative to the trigger.

Extends the button element.

Prop Default Type Description
asChild - boolean Whether to render the item as its child element.
onSelect - (e: { preventDefault: () => void }) => void Callback fired when the item is selected. Call `e.preventDefault()` to prevent the dropdown from closing.

Extends the div element.

Groups related items together with an optional heading.

Extends the div element.

A heading for a section of items.

A horizontal line to separate groups of items.

Extends the PopoverSearchInput component.

A styled input with a search icon, useful for filtering items.

Extends the PopoverEmpty component.

A styled container for empty state messages.

Examples

Simple

Basic usage with a list of items.

import { Button } from '@/components/button';
import {
  Dropdown,
  DropdownItem,
  DropdownItems,
  DropdownTrigger,
} from '@/components/dropdown';

export default function DropdownPreview() {
  return (
    <Dropdown>
      <DropdownTrigger asChild>
        <Button variant="outline">Open Menu</Button>
      </DropdownTrigger>
      <DropdownItems>
        <DropdownItem>Edit</DropdownItem>
        <DropdownItem>Duplicate</DropdownItem>
        <DropdownItem>Archive</DropdownItem>
        <DropdownItem disabled>Delete</DropdownItem>
      </DropdownItems>
    </Dropdown>
  );
}

With Sections

Using sections to group related items.

import { Button } from '@/components/button';
import {
  Dropdown,
  DropdownHeading,
  DropdownItem,
  DropdownItems,
  DropdownSection,
  DropdownTrigger,
} from '@/components/dropdown';

export default function DropdownSectionsPreview() {
  return (
    <Dropdown>
      <DropdownTrigger asChild>
        <Button variant="outline">Open Menu</Button>
      </DropdownTrigger>
      <DropdownItems>
        <DropdownSection>
          <DropdownHeading>Actions</DropdownHeading>
          <DropdownItem>Edit</DropdownItem>
          <DropdownItem>Duplicate</DropdownItem>
        </DropdownSection>
        <DropdownSection>
          <DropdownHeading>Danger Zone</DropdownHeading>
          <DropdownItem>Archive</DropdownItem>
          <DropdownItem disabled>Delete</DropdownItem>
        </DropdownSection>
      </DropdownItems>
    </Dropdown>
  );
}

With Icons

Adding icons to dropdown items.

import {
  ArchiveIcon,
  CopyIcon,
  PencilSimpleIcon,
  TrashIcon,
} from '@phosphor-icons/react/dist/ssr';

import { Button } from '@/components/button';
import {
  Dropdown,
  DropdownItem,
  DropdownItems,
  DropdownTrigger,
} from '@/components/dropdown';

export default function DropdownIconsPreview() {
  return (
    <Dropdown>
      <DropdownTrigger asChild>
        <Button variant="outline">Menu with Icons</Button>
      </DropdownTrigger>
      <DropdownItems>
        <DropdownItem>
          <PencilSimpleIcon />
          Edit
        </DropdownItem>
        <DropdownItem>
          <CopyIcon />
          Duplicate
        </DropdownItem>
        <DropdownItem>
          <ArchiveIcon />
          Archive
        </DropdownItem>
        <DropdownItem disabled>
          <TrashIcon />
          Delete
        </DropdownItem>
      </DropdownItems>
    </Dropdown>
  );
}

Multiple Selection

Use checkboxes and prevent the dropdown from closing on selection to allow multiple items to be selected.

'use client';

import { useState } from 'react';

import { Button } from '@/components/button';
import { Checkbox } from '@/components/checkbox';
import {
  Dropdown,
  DropdownItem,
  DropdownItems,
  DropdownTrigger,
} from '@/components/dropdown';

const people = [
  { id: 1, name: 'Durward Reynolds' },
  { id: 2, name: 'Kenton Towne' },
  { id: 3, name: 'Therese Wunsch' },
  { id: 4, name: 'Benedict Kessler' },
  { id: 5, name: 'Katelyn Rohan' },
];

export default function DropdownMultiplePreview() {
  const [selected, setSelected] = useState<number[]>([]);

  return (
    <Dropdown>
      <DropdownTrigger asChild>
        <Button variant="outline">Select People</Button>
      </DropdownTrigger>
      <DropdownItems>
        {people.map((person) => (
          <DropdownItem
            key={person.id}
            className="px-2"
            onSelect={(e) => {
              e.preventDefault(); // prevent close
              setSelected((prev) =>
                prev.includes(person.id)
                  ? prev.filter((id) => id !== person.id)
                  : [...prev, person.id]
              );
            }}
          >
            <Checkbox
              className="pointer-events-none"
              checked={selected.includes(person.id)}
              readOnly
            />
            <span>{person.name}</span>
          </DropdownItem>
        ))}
      </DropdownItems>
    </Dropdown>
  );
}

Search with Create Option

A complex example showing search functionality with the ability to create new items, and displaying selected items with avatars.

No people selected
'use client';

import { UserCircleIcon } from '@phosphor-icons/react/dist/ssr';
import { useMemo, useState } from 'react';

import { Avatar, AvatarFallback } from '@/components/avatar';
import { Button } from '@/components/button';
import { Checkbox } from '@/components/checkbox';
import {
  Dropdown,
  DropdownDivider,
  DropdownEmpty,
  DropdownItem,
  DropdownItems,
  DropdownSearchInput,
  DropdownTrigger,
} from '@/components/dropdown';

type Person = { id: number; name: string };

const initialPeople: Person[] = [
  { id: 1, name: 'Durward Reynolds' },
  { id: 2, name: 'Kenton Towne' },
  { id: 3, name: 'Therese Wunsch' },
  { id: 4, name: 'Benedict Kessler' },
  { id: 5, name: 'Katelyn Rohan' },
  { id: 6, name: 'Demetrius Ward' },
  { id: 7, name: 'Eleanora Fisher' },
  { id: 8, name: 'Augustus Palmer' },
  { id: 9, name: 'Cordelia Blake' },
  { id: 10, name: 'Sebastian Hayes' },
];

export default function DropdownSearchCreatePreview() {
  const [people, setPeople] = useState<Person[]>(initialPeople);
  const [selected, setSelected] = useState<Person[]>([]);
  const [search, setSearch] = useState('');

  const filteredPeople = useMemo(() => {
    return people.filter((person) =>
      person.name.toLowerCase().includes(search.toLowerCase())
    );
  }, [search, people]);

  const onOpenChange = (open: boolean) => {
    if (!open) {
      setTimeout(() => {
        setSearch('');
        setPeople((prev) => [
          ...selected,
          ...prev.filter((person) => !selected.some((s) => s.id === person.id)),
        ]);
      }, 200); // wait for menu to close
    }
  };

  return (
    <div className="w-96 rounded-3xl border border-border p-4">
      <Dropdown onOpenChange={onOpenChange}>
        <DropdownTrigger asChild>
          <Button variant="outline">Select People</Button>
        </DropdownTrigger>
        <DropdownItems>
          <DropdownSearchInput
            placeholder="Search people"
            value={search}
            onChange={(e) => setSearch(e.target.value)}
          />
          {filteredPeople.map((person) => (
            <DropdownItem
              key={person.id}
              className="px-2"
              onSelect={(e) => {
                e.preventDefault(); // prevent close
                setSelected((prev) =>
                  prev.some((p) => p.id === person.id)
                    ? prev.filter((p) => p.id !== person.id)
                    : [...prev, person]
                );
              }}
            >
              <Checkbox
                className="pointer-events-none"
                checked={selected.some((p) => p.id === person.id)}
                readOnly
              />
              <span>{person.name}</span>
            </DropdownItem>
          ))}
          {filteredPeople.length === 0 && (
            <DropdownEmpty>No results</DropdownEmpty>
          )}
          {search && (
            <>
              <DropdownDivider />
              <DropdownItem
                onSelect={() => {
                  const newPerson = {
                    id: people.length + 1,
                    name: search,
                  };
                  setPeople((prev) => [...prev, newPerson]);
                  setSelected((prev) => [...prev, newPerson]);
                  setSearch('');
                }}
              >
                Create &quot;{search}&quot;
              </DropdownItem>
            </>
          )}
        </DropdownItems>
      </Dropdown>
      {selected.length > 0 ? (
        <div className="mt-4 flex flex-wrap items-center gap-y-2 -space-x-2">
          {selected.map((person) => (
            <Avatar key={person.id} size="sm">
              <AvatarFallback>{person.name[0]}</AvatarFallback>
            </Avatar>
          ))}
        </div>
      ) : (
        <div className="mt-4 flex h-8 items-center gap-1 text-foreground-secondary text-sm">
          <UserCircleIcon className="text-base" /> No people selected
        </div>
      )}
    </div>
  );
}

Custom Items

Using custom item rendering for complex layouts.

import { Button } from '@/components/button';
import {
  Dropdown,
  DropdownItem,
  DropdownItems,
  DropdownTrigger,
} from '@/components/dropdown';

export default function DropdownCustomItemsPreview() {
  return (
    <Dropdown>
      <DropdownTrigger asChild>
        <Button variant="outline">Custom Items</Button>
      </DropdownTrigger>
      <DropdownItems>
        <DropdownItem className="flex flex-col items-start gap-1 text-left">
          <div className="font-medium">Custom Item 1</div>
          <div className="text-foreground-secondary text-sm">
            This is a description for the first item
          </div>
        </DropdownItem>
        <DropdownItem className="flex flex-col items-start gap-1 text-left">
          <div className="font-medium">Custom Item 2</div>
          <div className="text-foreground-secondary text-sm">
            This is a description for the second item
          </div>
        </DropdownItem>
        <DropdownItem className="flex items-center justify-between">
          <span>Custom Item 3</span>
          <span className="text-foreground-secondary text-sm">⌘K</span>
        </DropdownItem>
      </DropdownItems>
    </Dropdown>
  );
}

Best Practices

  1. Content Organization:

    • Group related items into sections
    • Use clear, descriptive labels
    • Keep item text concise
    • Consider using icons for visual clarity
  2. Interaction Design:

    • Use multiple selection when appropriate
    • Add search for long lists
    • Show empty states for filtered results
    • Consider keyboard users
  3. Mobile Considerations:

    • Ensure touch targets are large enough
    • Test on different screen sizes
    • Consider native alternatives for complex cases

Previous

Drawer

Next

Input