Dropdown
A Dropdown is an interactive overlay menu anchored to a trigger element. It is built on top of the Popover component and is designed for action menus, navigation lists, and nested submenus.
Key Characteristics
- Composable: Build menus declaratively using
Dropdown.Menu,Dropdown.Section,Dropdown.Item,Dropdown.Divider,Dropdown.Header, andDropdown.Footer. - Nestable: Create nested submenus by nesting
Dropdowncomponents - nesting is auto-detected via context. Nested dropdowns open on hover with a caret indicator. - Polymorphic Items: Render items as any element (
div,a,Link, etc.) using theasprop. - Accessible: Full keyboard navigation, focus management, and proper ARIA attributes built in.
Import
There are 7 dropdown-related components:
Dropdown: The main dropdown component.DropdownTrigger: The element that toggles the dropdown.DropdownMenu: The container for dropdown content.DropdownItem: A clickable menu item.DropdownSection: A group of items with an optional title.DropdownDivider: A visual separator between sections.DropdownHeader/DropdownFooter: Non-clickable top/bottom slots.
import {
Dropdown,
DropdownTrigger,
DropdownMenu,
DropdownItem,
DropdownSection,
DropdownDivider,
} from '@andrejground/lab';
Compound pattern is also supported:
Dropdown.TriggerDropdown.MenuDropdown.ItemDropdown.SectionDropdown.DividerDropdown.HeaderDropdown.Footer
Customization
Add your styles and your component is ready to be used.
- MyDropdown.tsx
- MyDropdown.module.scss
import { Dropdown, DropdownProps } from '@andrejground/lab';
import styles from './MyDropdown.module.scss';
type Props = DropdownProps;
function MyDropdown(props: Props) {
return (
<Dropdown
{...props}
classNames={{
popover: { content: styles.popoverContent },
item: { base: styles.itemBase },
section: { base: styles.sectionBase, title: styles.sectionTitle },
divider: { base: styles.dividerBase },
}}
/>
);
}
MyDropdown.Menu = Dropdown.Menu;
MyDropdown.Header = Dropdown.Header;
MyDropdown.Footer = Dropdown.Footer;
MyDropdown.Section = Dropdown.Section;
MyDropdown.Item = Dropdown.Item;
MyDropdown.Trigger = Dropdown.Trigger;
MyDropdown.Divider = Dropdown.Divider;
export default MyDropdown;
.popoverContent {
background-color: var(--background-color);
color: var(--text-color);
border: 1px solid var(--border-color);
}
.sectionTitle {
background-color: transparent;
font-size: 80%;
}
.itemBase {
&:hover,
&:active,
&:focus-visible,
&:focus-within,
&[data-highlighted-item='true'] {
background-color: var(--item-hover-bg-color);
}
}
.dividerBase {
background-color: var(--divider-color);
}
Usage
- Preview
- Code
import { Dropdown } from '@andrejground/lab';
export default function App() {
return (
<Dropdown>
<Dropdown.Trigger>
<button>Open Menu</button>
</Dropdown.Trigger>
<Dropdown.Menu>
<Dropdown.Item onClick={() => alert('New file')}>
New file
</Dropdown.Item>
<Dropdown.Item onClick={() => alert('Copy link')}>
Copy link
</Dropdown.Item>
<Dropdown.Item onClick={() => alert('Edit')}>
Edit
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
);
}
Sections
Use Dropdown.Section to group items with titles, and Dropdown.Divider to visually separate groups.
- Preview
- Code
import { Dropdown } from '@andrejground/lab';
export default function App() {
return (
<Dropdown>
<Dropdown.Trigger>
<button>Open Menu</button>
</Dropdown.Trigger>
<Dropdown.Menu>
<Dropdown.Section title="Actions">
<Dropdown.Item description="Create a new file">
New file
</Dropdown.Item>
<Dropdown.Item description="Copy the file link">
Copy link
</Dropdown.Item>
</Dropdown.Section>
<Dropdown.Divider />
<Dropdown.Section title="Danger zone">
<Dropdown.Item description="Permanently delete this file">
Delete
</Dropdown.Item>
</Dropdown.Section>
</Dropdown.Menu>
</Dropdown>
);
}
Nested
Nesting is automatic - simply place a Dropdown inside another Dropdown.Menu. The component detects its nesting level via context and adjusts placement, hover behavior, and caret visibility accordingly.
Keyboard navigation is fully supported between parent and child menus. Use Enter to open a nested menu, and Esc to close it.
When a nested dropdown lives inside another overlay (such as a Popover), closing the outer overlay closes nested menus without stealing focus from the outer trigger. Only the root popover restores focus on an outside click.
- Preview
- Code
import { Dropdown } from '@andrejground/lab';
export default function App() {
return (
<Dropdown>
<Dropdown.Trigger>
<button>Actions</button>
</Dropdown.Trigger>
<Dropdown.Menu>
<Dropdown.Item>New file</Dropdown.Item>
<Dropdown.Item>Copy link</Dropdown.Item>
<Dropdown>
<Dropdown.Trigger>
<Dropdown.Item shouldCloseOnSelection={false}>
Export
</Dropdown.Item>
</Dropdown.Trigger>
<Dropdown.Menu>
<Dropdown.Item>JSON</Dropdown.Item>
<Dropdown.Item>CSV</Dropdown.Item>
<Dropdown.Item>PDF</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</Dropdown.Menu>
</Dropdown>
);
}
Disabled Items
Use the disabled prop on Dropdown.Item to prevent interaction.
- Preview
- Code
import { Dropdown } from '@andrejground/lab';
export default function App() {
return (
<Dropdown>
<Dropdown.Trigger>
<button>Open Menu</button>
</Dropdown.Trigger>
<Dropdown.Menu>
<Dropdown.Item>New file</Dropdown.Item>
<Dropdown.Item disabled>Copy link (disabled)</Dropdown.Item>
<Dropdown.Item>Edit</Dropdown.Item>
<Dropdown.Item disabled>Delete (disabled)</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
);
}
Infinite Scroll
Use infiniteScrollProps on Dropdown.Section to lazily load items as the user scrolls. The section needs the scrolling prop to enable a scrollable container.
- Preview
- Code
import React from 'react';
import { Dropdown } from '@andrejground/lab';
// Example hook that fetches paginated data
function usePokemonList() {
const [items, setItems] = React.useState<{ text: string; value: string }[]>([]);
const [hasMore, setHasMore] = React.useState(true);
const [isLoading, setIsLoading] = React.useState(false);
const [offset, setOffset] = React.useState(0);
const limit = 8;
const loadPokemon = React.useCallback(async (currentOffset: number) => {
setIsLoading(true);
const res = await fetch(
`https://pokeapi.co/api/v2/pokemon?offset=${currentOffset}&limit=${limit}`,
);
const json = await res.json();
setHasMore(json.next !== null);
setItems((prev) => {
const loaded = json.results.map((p) => ({ text: p.name, value: p.url }));
return [...new Map([...prev, ...loaded].map((i) => [i.value, i])).values()];
});
setIsLoading(false);
}, []);
React.useEffect(() => { loadPokemon(0); }, []);
const onLoadMore = React.useCallback(() => {
const next = offset + limit;
setOffset(next);
loadPokemon(next);
}, [offset, loadPokemon]);
return { items, hasMore, isLoading, onLoadMore };
}
export default function App() {
const { items, isLoading, onLoadMore, hasMore } = usePokemonList();
return (
<Dropdown>
<Dropdown.Trigger>
<button>
Pokémons ▾
</button>
</Dropdown.Trigger>
<Dropdown.Menu>
<Dropdown.Section
title="Pokémons"
scrolling
infiniteScrollProps={{
onLoadMore,
hasMore,
isLoading,
}}
>
{items.map((item) => (
<Dropdown.Item key={item.value}>
{item.text}
</Dropdown.Item>
))}
</Dropdown.Section>
</Dropdown.Menu>
</Dropdown>
);
}
Polymorphic Items
Use the as prop to render items as different elements - links, anchors, or any other component.
- Preview
- Code
import { Dropdown } from '@andrejground/lab';
export default function App() {
return (
<Dropdown>
<Dropdown.Trigger>
<button>Navigation</button>
</Dropdown.Trigger>
<Dropdown.Menu>
<Dropdown.Item as="a" href="/blog" description="Visit the blog">
Blog (Link)
</Dropdown.Item>
<Dropdown.Item as="a" href="https://github.com" target="_blank" description="Open GitHub">
GitHub (anchor)
</Dropdown.Item>
<Dropdown.Item onClick={() => alert('Clicked!')} description="Triggers an action">
Action (div)
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
);
}
Controlled
- Preview
- Code
falseimport React from 'react';
import { Dropdown } from '@andrejground/lab';
export default function App() {
const [isOpen, setIsOpen] = React.useState(false);
return (
<>
<Dropdown isOpen={isOpen} onOpenChange={setIsOpen}>
<Dropdown.Trigger>
<button>Open Menu</button>
</Dropdown.Trigger>
<Dropdown.Menu>
<Dropdown.Item>New file</Dropdown.Item>
<Dropdown.Item>Copy link</Dropdown.Item>
<Dropdown.Item>Edit</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<br />
<div>
Open: <code>{`${isOpen}`}</code>
</div>
</>
);
}
API
Dropdown Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Dropdown content (expects Dropdown.Trigger and Dropdown.Menu) |
trigger | ReactNode | - | Element that triggers the dropdown |
shouldCloseOnSelection | boolean | true | Whether the dropdown closes after an item is selected |
caret | ReactNode | - | Custom caret element replacing the default indicator |
showCaret | boolean | false (root) / true (nested) | Shows or hides the caret indicator |
autoFocus | "first-item" | "last-item" | "menu" | "none" | "menu" | Controls which item receives focus when the dropdown opens |
isOpen | boolean | - | Controls the dropdown open state (controlled mode) |
isDisabled | boolean | - | Disables the dropdown trigger |
placement | PopoverPlacement | "bottom-center" (root) / "right-start" (nested) | Preferred placement of the dropdown relative to the trigger |
offset | number | - | Distance in pixels between the dropdown and the trigger |
backdrop | "none" | "transparent" | "opaque" | "blur" | - | The backdrop style displayed behind the dropdown |
shouldFlip | boolean | true | Flips placement when there is not enough space |
shouldBlockScroll | boolean | true | Blocks page scroll when the dropdown is open |
shouldCloseOnScroll | boolean | !shouldBlockScroll | Closes the dropdown when the page is scrolled |
shouldCloseOnClickOutside | boolean | true | Closes the dropdown when clicking outside of it |
shouldCloseOnEsc | boolean | true | Closes the dropdown when the Escape key is pressed |
openOnHover | boolean | false (root) / true (nested) | Opens the dropdown on hover instead of click |
showArrow | boolean | false | Shows an arrow pointing toward the trigger |
growContent | boolean | - | Allows the dropdown content to grow beyond the trigger width |
onOpen | () => void | - | Callback fired when the dropdown opens |
onClose | () => void | - | Callback fired when the dropdown closes |
onClickOutside | (event: MouseEvent | TouchEvent) => void | - | Callback fired when clicking outside the dropdown |
onOpenChange | (isOpen: boolean) => void | - | Callback fired when the open state changes |
focusTriggerOnClose | boolean | true | Returns focus to the dropdown trigger when the menu closes after a selection (handleCloseRoot). Passed through to the underlying Popover |
onTriggerFocus | () => void | - | Callback fired when the trigger receives focus |
onTriggerBlur | () => void | - | Callback fired when the trigger loses focus |
triggerWrapper | boolean | - | Wraps the trigger in a span instead of using Slot |
fullWidthTriggerWrapper | boolean | - | Makes the trigger wrapper take full width |
focusTrapProps | { trapFocus?: boolean; autoFocus?: boolean } | { trapFocus: true, autoFocus: autoFocus === "none" } | Configuration for focus trap behavior |
classNames | DropdownClassNames | - | Custom class names for the dropdown slots |
Dropdown.Trigger Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Trigger content |
classNames | DropdownTriggerClassNames | - | Custom class names for the trigger slots |
Dropdown.Menu Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Menu content (Dropdown.Section, Dropdown.Item, etc.) |
classNames | DropdownMenuClassNames | - | Custom class names for the menu slots |
Dropdown.Section Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Section content |
scrolling | boolean | - | Enables scrolling within this section |
title | ReactNode | - | Title displayed above the section |
isStickyTitle | boolean | true | Keeps the section title visible while the section scrolls |
infiniteScrollProps | InfiniteScrollProps | - | Configuration for infinite scrolling within this section |
classNames | DropdownSectionClassNames | - | Custom class names for the section slots |
Dropdown.Item Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Item content |
isHighlighted | boolean | - | Visually highlights the item |
shouldCloseOnSelection | boolean | - | Whether selecting this item closes the dropdown |
disabled | boolean | - | Disables the item |
showDisabledStyles | boolean | disabled | Shows disabled visual styles without disabling interaction |
startContent | ReactNode | - | Content rendered before the item text |
endContent | ReactNode | - | Content rendered after the item text |
as | ElementType | "div" | Renders the item as a different HTML element or component |
description | string | - | Description text displayed below the item content |
classNames | DropdownItemClassNames | - | Custom class names for the item slots |
Dropdown.Header Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Header content |
isSticky | boolean | - | Keeps the header visible while the menu scrolls |
classNames | DropdownHeaderClassNames | - | Custom class names for the header slots |
Dropdown.Footer Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Footer content |
isSticky | boolean | - | Keeps the footer visible while the menu scrolls |
classNames | DropdownFooterClassNames | - | Custom class names for the footer slots |
Dropdown.Divider Props
| Prop | Type | Default | Description |
|---|---|---|---|
classNames | DropdownDividerClassNames | - | Custom class names for the divider slots |