Skip to content
cennge-ui
GitHubv0.3.1, changelog

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

  1. Open a menu with a click, or with Enter, Space or ↓ while its button has focus.
  2. Arrows move between items and pass over the disabled one; a typed letter jumps to the item that starts with it.
  3. Enter runs the item and closes the menu; Escape closes it and puts focus back on the button.
Live: the component itself, not a picture of it.
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 isOpen with onOpenChange
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

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.

actions.tsx
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.

account.tsx
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-ui
keep-open.tsx
import { 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.

dialog.tsx
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#

PropTypeDefault
isOpen
boolean
–
defaultOpen
boolean
–
onOpenChange
(isOpen: boolean) => void
–
isModal
booleanBlocks the rest of the page while open. Turn off when a menu item opens a Dialog, so the two modal layers do not fight over pointer events.
true
children
React.ReactNode
–
PropTypeDefault
placement
"left""right""top""bottom"
"bottom"
align
"start""center""end"
"start"
offset
numberGap from the trigger, in px.
6
container
HTMLElement | null
–

Also takes every attribute of <div>.

PropTypeDefault
color
"default""primary""secondary""success""warning""danger""info"Highlight colour. Use danger for destructive actions.
"default"
onAction
() => voidCalled when the item is chosen by click, Enter or Space.
–
description
React.ReactNodeSecond line under the label.
–
startContent
React.ReactNode
–
endContent
React.ReactNode
–
shortcut
React.ReactNodeKeyboard hint shown on the right, e.g. "⌘K".
–
isDisabled
boolean
–
keepOpen
booleanKeep the menu open after this item is chosen.
false

Also takes every attribute of <div>, except color, onSelect, which mean something else here.