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
Dropdown
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. |
DropdownTrigger
Extends the PopoverTrigger component.
DropdownItems
Extends the PopoverContent component.
The content will be rendered in a portal and will be positioned relative to the trigger.
DropdownItem
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. |
DropdownSection
Extends the div element.
Groups related items together with an optional heading.
DropdownHeading
Extends the div element.
A heading for a section of items.
DropdownDivider
A horizontal line to separate groups of items.
DropdownSearchInput
Extends the PopoverSearchInput component.
A styled input with a search icon, useful for filtering items.
DropdownEmpty
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.
'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 "{search}"
</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
-
Content Organization:
- Group related items into sections
- Use clear, descriptive labels
- Keep item text concise
- Consider using icons for visual clarity
-
Interaction Design:
- Use multiple selection when appropriate
- Add search for long lists
- Show empty states for filtered results
- Consider keyboard users
-
Mobile Considerations:
- Ensure touch targets are large enough
- Test on different screen sizes
- Consider native alternatives for complex cases
Previous
Drawer
Next
Input