Dropdown
A menu of actions behind a button, with icons, second lines and shortcut hints, and destructive items in red.
Repositories
cennge-ui
React UI library on Radix UI and Tailwind CSS v4
payload-cache
FastAPI cache service
ai-niche-scout
Finds niches worth building for
- Open a menu with a click, or with Enter, Space or ↓ while its button has focus.
- Arrows move between items and pass over the disabled one; a typed letter jumps to the item that starts with it.
- Enter runs the item and closes the menu; Escape closes it and puts focus back on the button.
Field marks
- Import
import { Dropdown, DropdownTrigger, DropdownContent, DropdownItem } from "cennge-ui";- Size
- 1.3 KB gzip for this import, its dependencies aside
- Pattern
- WAI-ARIA menu button
- Keyboard
- Enter SpaceArrow down Arrow up HomeEndEsc
- State
- Uncontrolled, or
isOpenwithonOpenChange - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Typeahead
- A typed letter focuses the next item that starts with it
- Modal
- Blocks the page while open;
isModal={false}lets it through - Tests
- end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: overlays
- Source
- src/components/dropdown
When to use#
A Dropdown holds the actions that do not each earn a button of their own: the actions of a table row, the overflow of a toolbar, an account. Each item runs once and the menu closes behind it.
Similar#
- Select picks a value and keeps it in its trigger; a Dropdown's items run actions and leave nothing selected.
- ContextMenu takes the same items and opens at the pointer on right click or long press; a Dropdown opens from a button that everyone can see and reach.
- Command finds an action by typing, for more actions than fit in a short menu.
- NavigationMenu holds links for getting around a site; a Dropdown holds actions.
Actions and a destructive one#
shortcut shows a key at the right of the item. It is only a hint: binding the key is up to you. color="danger" keeps an item red at rest, not only when it is highlighted, and a separator sets it apart from the rest.
import { Button, Dropdown, DropdownContent, DropdownItem, DropdownSeparator, DropdownTrigger, toast } from "cennge-ui";
export default function IssueActions() {
return (
<Dropdown>
<DropdownTrigger>
<Button variant="faded">Actions</Button>
</DropdownTrigger>
<DropdownContent>
<DropdownItem shortcut="E" onAction={() => toast("Editing issue #12")}>
Edit
</DropdownItem>
<DropdownItem shortcut="L" onAction={() => toast("Copied the link to issue #12")}>
Copy link
</DropdownItem>
<DropdownItem shortcut="P" onAction={() => toast("Pinned issue #12")}>
Pin to the top
</DropdownItem>
<DropdownSeparator />
<DropdownItem color="danger" onAction={() => toast.danger("Closed issue #12")}>
Close issue
</DropdownItem>
</DropdownContent>
</Dropdown>
);
}An account menu#
The trigger can be any single element that takes a ref and focus, here a plain <button> around an Avatar. align="end" lines the menu up with the trigger's right edge. DropdownLabel is a line of text above the items, not an item, so the arrows pass over it; DropdownGroup wraps related items in a group.
import {
Avatar,
Dropdown,
DropdownContent,
DropdownGroup,
DropdownItem,
DropdownLabel,
DropdownSeparator,
DropdownTrigger,
toast,
} from "cennge-ui";
export default function AccountMenu() {
return (
<Dropdown>
<DropdownTrigger>
<button
type="button"
className="cursor-pointer rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary focus-visible:outline-solid"
>
<Avatar name="Dev" color="primary" aria-hidden="true" />
<span className="sr-only">Account</span>
</button>
</DropdownTrigger>
<DropdownContent align="end">
<DropdownLabel>dev@example.com</DropdownLabel>
<DropdownGroup>
<DropdownItem onAction={() => toast("Your profile")}>Profile</DropdownItem>
<DropdownItem onAction={() => toast("Your repositories")}>Repositories</DropdownItem>
<DropdownItem onAction={() => toast("Settings")}>Settings</DropdownItem>
</DropdownGroup>
<DropdownSeparator />
<DropdownItem onAction={() => toast("Signed out")}>Sign out</DropdownItem>
</DropdownContent>
</Dropdown>
);
}Keep the menu open#
An item with keepOpen runs and leaves the menu open, for steps people repeat, like changing the text size one pixel at a time. The label above the items shows the size as it changes.
pnpm add cennge-uiimport { useState } from "react";
import { Button, Dropdown, DropdownContent, DropdownItem, DropdownLabel, DropdownTrigger } from "cennge-ui";
export default function TextSize() {
const [size, setSize] = useState(14);
return (
<div className="flex w-full flex-wrap items-center gap-4">
<Dropdown>
<DropdownTrigger>
<Button variant="flat">View</Button>
</DropdownTrigger>
<DropdownContent>
<DropdownLabel>Text size: {size}px</DropdownLabel>
<DropdownItem keepOpen isDisabled={size >= 20} onAction={() => setSize(size + 1)}>
Larger
</DropdownItem>
<DropdownItem keepOpen isDisabled={size <= 11} onAction={() => setSize(size - 1)}>
Smaller
</DropdownItem>
<DropdownItem onAction={() => setSize(14)}>Reset</DropdownItem>
</DropdownContent>
</Dropdown>
<code className="font-mono text-muted" style={{ fontSize: size }}>
pnpm add cennge-ui
</code>
</div>
);
}An item that opens a dialog#
Keep the Dialog outside the menu, open it from onAction, and set isModal={false} on the Dropdown so the menu and the dialog do not both hold the page. By the time the dialog closes, the item that opened it is gone, so onCloseAutoFocus sends focus back to the menu's button.
import { useRef, useState } from "react";
import {
Button,
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Dropdown,
DropdownContent,
DropdownItem,
DropdownTrigger,
Input,
toast,
} from "cennge-ui";
export default function RenameFromMenu() {
const [name, setName] = useState("payload-cache");
const [draft, setDraft] = useState(name);
const [isRenaming, setRenaming] = useState(false);
const menuButton = useRef<HTMLButtonElement>(null);
function rename() {
setName(draft);
setRenaming(false);
toast.success(`Renamed to ${draft}`);
}
return (
<>
<Dropdown isModal={false}>
<DropdownTrigger>
<Button ref={menuButton} variant="flat">
{name}
</Button>
</DropdownTrigger>
<DropdownContent>
<DropdownItem
onAction={() => {
setDraft(name);
setRenaming(true);
}}
>
Rename…
</DropdownItem>
<DropdownItem onAction={() => toast(`Opened ${name} settings`)}>Settings</DropdownItem>
</DropdownContent>
</Dropdown>
<Dialog isOpen={isRenaming} onOpenChange={setRenaming}>
<DialogContent
size="sm"
onCloseAutoFocus={(event) => {
// The menu item that opened the dialog is gone: go back to the menu's button.
event.preventDefault();
menuButton.current?.focus();
}}
>
<DialogHeader>
<DialogTitle>Rename repository</DialogTitle>
<DialogDescription>A new name for {name}.</DialogDescription>
</DialogHeader>
<form
className="contents"
onSubmit={(event) => {
event.preventDefault();
rename();
}}
>
<DialogBody>
<Input label="Name" value={draft} onValueChange={setDraft} isRequired />
</DialogBody>
<DialogFooter>
<DialogClose>
<Button variant="flat">Cancel</Button>
</DialogClose>
<Button type="submit" color="primary" isDisabled={!draft.trim() || draft === name}>
Rename
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}Accessibility#
The trigger gets aria-haspopup="menu" and aria-expanded; the menu and its items have the roles menu and menuitem, and a disabled item is aria-disabled. While a modal Dropdown is open, the rest of the page is hidden from screen readers and ignores the pointer, so the menu is all there is until it closes.
Styling#
DropdownContent is at least 13rem wide and never taller than the room between the trigger and the edge of the window; a longer menu scrolls. Its className and each item's className are merged last, so they win over the defaults.
Props#
Dropdown#
DropdownContent#
Also takes every attribute of <div>.
DropdownItem#
Also takes every attribute of <div>, except color, onSelect, which mean something else here.