---
title: Dropdown Menu
description: A menu hung off a button: the popover API for the layer, arrow keys for the list, and light-dismiss for free.
source: https://adamjurek.com/components/dropdown-menu
---

## Home


One paragraph on what this does and when to reach for it. This is the first
thing on the component's page and the line that goes into `llms.txt`, so it
should survive being read on its own.

<!-- ::start:showcase demo="dropdown-menu" height="380" -->
<!-- ::end:showcase -->

## Why it is built this way

The decision somebody would otherwise have to reverse-engineer from the source.
Not what the code does - the source says that - but what it is avoiding.

## What it does not do

The boundary. A component that lists what it is not is a component you can
decide against in ten seconds.

> [!NOTE] Install commands are not written here
> Anything in `packages/ui/registry.ts` gets its TanStack, shadcn and pnpm
> commands appended to the bottom of this tab, along with its version,
> dependencies and files. Do not add your own - the generated ones cannot go
> stale, and a second copy immediately does.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/dropdown-menu.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/dropdown-menu.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | layout · Overlays |
| Files | `dropdown-menu.tsx` |
| Dependencies | None |
| Also installs | `icon` |
| Tags | block, menu, popover, keyboard, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { DropdownMenu } from "@sushindustries/ui";
import "@sushindustries/atoms/atoms.css";

export function Example() {
	return (
		<DropdownMenu
			label="Actions"
			icon="terminal"
			items={[
				{ id: "open", label: "Open on the site", icon: "link" },
				{ id: "retitle", label: "Change title…", icon: "text" },
				{ id: "remove", label: "Remove…", icon: "close", destructive: true },
			]}
			onSelect={(id) => console.log(id)}
		/>
	);
}
```

## What you should see

A quiet small button with a chevron pointing right. Clicking it turns the
chevron down and opens a menu under the button's left edge, with `Remove…` in
the palette's one warm red. Clicking anywhere else closes it, Escape closes it
and puts focus back on the button, and the arrow keys walk the items.

## If nothing happens

A menu that opens centred in the middle of the viewport, full width, with a
border you did not ask for, is the user agent's own popover styling:
`@sushindustries/atoms/atoms.css` was never imported, and the resets that undo
`inset: 0` live in it. A menu that never opens at all means the browser has no
popover API, which nothing this component does can work around.


## Guides


## Composing it

Items are data, not children. `onSelect` is handed the item's id and is not
called for a disabled one, so a caller never has to check. `buttonClassName`
replaces the button's classes outright, which is how the same menu becomes an
icon in a table cell.

```tsx
<DropdownMenu
	label="Actions"
	buttonClassName="btn btn-quiet btn-icon"
	align="end"
	items={rows.length ? actions : []}
	empty="Select a row first."
	onSelect={run}
/>
```

An empty `items` says so in the menu rather than by leaving a button that does
nothing, or by a button that is not there at all.

## Which edge it lines up with

Position is measured on `beforetoggle`, while the popover is still closed and
already measurable, so the menu is placed before the first frame it is visible
in. It flips above the button when there is no room below and is clamped into
the viewport sideways.

| `align` | Lines up | For |
| --- | --- | --- |
| `start` (default) | The menu's left edge with the button's left edge | A menu under a control on the left of its row |
| `end` | The two right edges | The last column of a table, where a left-aligned menu would hang off the page |

## What the platform does, and what is left

Four of the things a menu needs are already in the browser, and are done better
there than a component can do them:

| The browser's | What it means here |
| --- | --- |
| The top layer | Over every stacking context, without a z-index war |
| Light dismiss | A click anywhere else closes it |
| Escape | Closes it, and returns focus to the invoker |
| One at a time | Opening another `popover="auto"` closes this one |

What is left is placement and the arrow keys. That is the whole component, and
it is why it has no dependency beyond `Icon`. Not CSS anchor positioning, which
would delete the placement code and is Chromium-only - a menu that lands in the
top left corner in Safari is not a progressive enhancement.

## When not to use it

`ContextMenu` is the other half of this idea and is not interchangeable with
it: that one opens at a pointer from a right-click or a long press. This one
opens from a control somebody clicked on purpose, which is what makes it
reachable by keyboard from that control.

`destructive` is colour only and confirms nothing. An action that needs a
second look needs a `Dialog`, and this menu is not one.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `label` | `string` | - | The button's text. Also the menu's accessible name. |
| `items` | `readonly DropdownItem[]` | - |  |
| `onSelect` | `(id: string) => void` | - | Called with the item's id. Not called for a disabled item. |
| `icon?` | `IconName` | - | Drawn in the button, before its label. |
| `align?` | `"start" \| "end"` | `"start"` | Which edge the menu lines up with. `end` for a right-hand column. |
| `buttonClassName?` | `string` | `"btn btn-quiet btn-sm"` | Replaces the button's classes, for a menu that is an icon in a table. |
| `empty?` | `string` | `"Nothing to do here."` | Nothing to do here, said in the menu rather than by a missing button. |

<!-- /generated:api -->

## Notes

Anything the types cannot say: which combinations are meaningless, which
prop is ignored when another is set, and what it does when handed
something it cannot render.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="dropdown-menu" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## The row menu in a table

This is the job the component was written for. The menu lives in a scrolling
cell, which is exactly where a portal-free menu gets clipped - here it does
not, because the top layer is positioned against the viewport and not against
any ancestor.

```tsx
import { DataTable, DropdownMenu } from "@sushindustries/ui";

export function Documents({ rows, run }) {
	return (
		<DataTable
			label="Documents"
			rows={rows}
			columns={[
				{ id: "title", header: "Title" },
				{
					id: "id",
					header: "",
					cell: (row) => (
						<DropdownMenu
							label="Actions"
							buttonClassName="btn btn-quiet btn-icon"
							align="end"
							items={[
								{ id: "open", label: "Open on the site", icon: "link" },
								{ id: "retitle", label: "Change title…", icon: "text" },
								{
									id: "remove",
									label: "Remove…",
									icon: "close",
									destructive: true,
									disabled: row.published,
								},
							]}
							onSelect={(action) => run(action, row)}
						/>
					),
				},
			]}
		/>
	);
}
```

## What this example is not

`Remove…` is red and disabled on a published row, and neither of those is a
confirmation - selecting it calls `run` immediately. Put a `Dialog` behind that
action if it needs a second look. `align="end"` is right because this is the
last column; in the first column it would push the menu off the left edge, and
the clamp would drag it back rather than align it.
