Stagger dropdown
Free · MITA dropdown menu springs open while its items stagger in, with a highlight that glides between items, submenus, checkbox and radio items, and full keyboard navigation.
Installation
$ npx shadcn@latest add https://goodui.dev/r/dropdown-stagger.jsonThe file lands in components/goodui/dropdown-stagger.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/dropdown-stagger works too.
Usage
import { Bell, CalendarClock, Ellipsis, Pencil, XCircle } from "lucide-react"
import {
DropdownStagger,
DropdownStaggerItem,
DropdownStaggerSeparator,
DropdownStaggerSub,
} from "@/components/goodui/dropdown-stagger"
export function BookingActions({ client, cancelled, onAction }: { client: string; cancelled: boolean; onAction: (action: string) => void }) {
return (
<DropdownStagger
align="end"
triggerLabel={`Actions for ${client}`}
triggerClassName="size-8 border-transparent bg-transparent px-0 shadow-none"
trigger={<Ellipsis />}
>
<DropdownStaggerItem icon={<Pencil />} onSelect={() => onAction("edit")}>
Edit booking
</DropdownStaggerItem>
<DropdownStaggerSub icon={<CalendarClock />} label="Reschedule">
{["Today 5:45 PM", "Tomorrow 10:00 AM", "Fri 9:15 AM"].map((slot) => (
<DropdownStaggerItem key={slot} onSelect={() => onAction(`move:${slot}`)}>
{slot}
</DropdownStaggerItem>
))}
</DropdownStaggerSub>
<DropdownStaggerItem icon={<Bell />} shortcut="⌘R" onSelect={() => onAction("remind")}>
Send reminder
</DropdownStaggerItem>
<DropdownStaggerSeparator />
<DropdownStaggerItem icon={<XCircle />} variant="destructive" disabled={cancelled} onSelect={() => onAction("cancel")}>
Cancel booking
</DropdownStaggerItem>
</DropdownStagger>
)
}When to use
- Row actions in a table or list: edit, reschedule, send a reminder, cancel.
- An account menu behind an avatar: profile, billing, team, sign out.
- View options for a list: checkbox items for what to show, radio items for the sort order.
- Picking a value for a form field: use select, which behaves like a form control and shows the chosen value.
- Searching across many commands: use command-palette-morph.
- A small form behind a button (a note, a code): use popover-morph.
| If you need | Use |
|---|---|
| Run an action | DropdownStaggerItem with onSelect (closes the menu) |
| Delete, cancel or remove | DropdownStaggerItem variant="destructive", after a separator |
| Toggle several options without closing | DropdownStaggerCheckboxItem (closeOnSelect defaults to false) |
| Choose one option such as a sort order | DropdownStaggerRadioGroup with DropdownStaggerRadioItem |
| A second level, such as time slots | DropdownStaggerSub |
| Icon-only trigger | triggerLabel plus a compact triggerClassName |
Examples
View options with checkbox and radio items
<DropdownStagger trigger={<><SlidersHorizontal /> View</>}>
<DropdownStaggerLabel>Show</DropdownStaggerLabel>
<DropdownStaggerCheckboxItem checked={showCancelled} onCheckedChange={setShowCancelled}>
Cancelled bookings
</DropdownStaggerCheckboxItem>
<DropdownStaggerCheckboxItem checked={showPrices} onCheckedChange={setShowPrices}>
Prices
</DropdownStaggerCheckboxItem>
<DropdownStaggerSeparator />
<DropdownStaggerLabel>Sort by</DropdownStaggerLabel>
<DropdownStaggerRadioGroup value={sort} onValueChange={setSort}>
<DropdownStaggerRadioItem value="time">Start time</DropdownStaggerRadioItem>
<DropdownStaggerRadioItem value="client">Client name</DropdownStaggerRadioItem>
<DropdownStaggerRadioItem value="price">Highest value</DropdownStaggerRadioItem>
</DropdownStaggerRadioGroup>
</DropdownStagger>Keep the menu open after an action
<DropdownStaggerItem icon={<Copy />} onSelect={(e) => { e.preventDefault(); copyLink() }}>
Copy link
</DropdownStaggerItem>API reference
DropdownStagger
| Prop | Type | Default |
|---|---|---|
| trigger* Content of the trigger button. | React.ReactNode | — |
| children* Items, labels, separators, radio groups and submenus. | React.ReactNode | — |
| triggerLabel Accessible name when the trigger is icon-only. Rendered as sr-only text after the trigger content, so any visible text stays the start of the name. | string | — |
| triggerClassName Classes for the trigger button. | string | — |
| contentClassName Classes for the menu panel. | string | — |
| align Which edge of the trigger the menu lines up with (logical: start is the left edge in LTR, the right edge in RTL). It does not flip near the viewport edge. | "start" | "end" | "start" |
| open Controlled open state. Pair with onOpenChange. | boolean | — |
| defaultOpen Initial open state when uncontrolled. | boolean | false |
| onOpenChange Called with the next state on trigger use, item selection, Esc, Tab or an outside click. | (open: boolean) => void | — |
Also accepts every <div> attribute.
DropdownStaggerItem
| Prop | Type | Default |
|---|---|---|
| children* Content. | React.ReactNode | — |
| icon Leading icon, tinted muted unless the item is destructive. | React.ReactNode | — |
| shortcut Hint shown on the right, e.g. "⌘E". It is display text only; it does not bind the shortcut. | string | — |
| variant "destructive" colors the text and highlight with the destructive token. | "default" | "destructive" | "default" |
| disabled Grays the item out; arrow keys and typeahead skip it and clicks do nothing. | boolean | — |
| onSelect Call event.preventDefault() to keep the menu open. | (event: React.MouseEvent<HTMLButtonElement>) => void | — |
| closeOnSelect Close the menu and return focus to the trigger after selecting. Defaults to true for items, false for checkbox and radio items. | boolean | true |
| className Classes merged onto the root element. | string | — |
DropdownStaggerCheckboxItem
| Prop | Type | Default |
|---|---|---|
| children* Content. | React.ReactNode | — |
| checked* Whether the check is drawn. The item is always controlled. | boolean | — |
| onCheckedChange* Called with the toggled value on click, Enter or Space. | (checked: boolean) => void | — |
| className Classes merged onto the root element. | string | — |
| onSelect Call event.preventDefault() to keep the menu open. | (event: React.MouseEvent<HTMLButtonElement>) => void | — |
| disabled Grays the item out; arrow keys and typeahead skip it and clicks do nothing. | boolean | — |
| shortcut Hint shown on the right, e.g. "⌘E". It is display text only; it does not bind the shortcut. | string | — |
| closeOnSelect Close the menu and return focus to the trigger after selecting. Defaults to true for items, false for checkbox and radio items. | boolean | false |
DropdownStaggerRadioGroup
| Prop | Type | Default |
|---|---|---|
| value* Value of the selected radio item. | string | — |
| onValueChange* Called with the chosen item's value. | (value: string) => void | — |
| children* Content. | React.ReactNode | — |
| label Accessible name of the group. Without it, a DropdownStaggerLabel right before (or inside) the group names it. | string | — |
| aria-labelledby Id of an element that names the group; overrides the automatic DropdownStaggerLabel link. | string | — |
DropdownStaggerRadioItem
| Prop | Type | Default |
|---|---|---|
| children* Content. | React.ReactNode | — |
| value* Value reported to the surrounding DropdownStaggerRadioGroup; checked when it equals the group's value. | string | — |
| className Classes merged onto the root element. | string | — |
| onSelect Call event.preventDefault() to keep the menu open. | (event: React.MouseEvent<HTMLButtonElement>) => void | — |
| disabled Grays the item out; arrow keys and typeahead skip it and clicks do nothing. | boolean | — |
| shortcut Hint shown on the right, e.g. "⌘E". It is display text only; it does not bind the shortcut. | string | — |
| closeOnSelect Close the menu and return focus to the trigger after selecting. Defaults to true for items, false for checkbox and radio items. | boolean | false |
DropdownStaggerSub
| Prop | Type | Default |
|---|---|---|
| label* Text of the item that opens the submenu; it also names the submenu. | React.ReactNode | — |
| children* Content. | React.ReactNode | — |
| icon Leading icon for the submenu item. | React.ReactNode | — |
| disabled Grays the item out; the submenu cannot open. | boolean | — |
| className Classes merged onto the root element. | string | — |
DropdownStaggerLabel
| Prop | Type | Default |
|---|---|---|
| children* Content. | React.ReactNode | — |
| id | string | — |
| className Classes merged onto the root element. | string | — |
DropdownStaggerSeparator
| Prop | Type | Default |
|---|---|---|
| className Classes merged onto the root element. | string | — |
Accessibility
| Enter / Space / ArrowDown | On the trigger: opens the menu and focuses the first item. |
| ArrowUp | On the trigger: opens the menu and focuses the last item. |
| ArrowDown / ArrowUp | Moves to the next or previous enabled item, wrapping at the ends. |
| Home / End | Moves to the first or last enabled item. |
| Enter / Space | Activates the focused item, toggles a checkbox item or picks a radio item. |
| ArrowRight / Enter / Space | On a submenu item: opens the submenu and focuses its first item. |
| ArrowLeft | Inside a submenu: closes it and returns to its parent item. |
| Esc | Closes the submenu you are in, or the menu, returning focus to its trigger. |
| Tab | Closes the whole menu and returns focus to the trigger. |
| Letters | Typeahead: jumps to the next item whose text starts with the typed letters (600 ms window). |
- The trigger is a <button> with aria-haspopup="menu", aria-expanded and aria-controls; triggerLabel adds sr-only text after the trigger content, so icon-only triggers get a name and visible text stays the start of it.
- The panel is role="menu" (aria-orientation vertical) labelled by its trigger. Items use menuitem, menuitemcheckbox and menuitemradio with aria-checked; submenu items have aria-haspopup and aria-expanded.
- A radio group is named by the DropdownStaggerLabel right before (or inside) it through aria-labelledby; pass label or aria-labelledby to name it yourself.
- Under dir="rtl" ArrowLeft opens a submenu and ArrowRight closes it; submenus open to the left first and items slide in from the other side.
- Disabled items get aria-disabled, are skipped by arrow keys and typeahead, and ignore clicks.
- Selecting an item, Esc and Tab return focus to the trigger; an outside click closes the menu without moving focus.
- Hovering an item moves focus to it, so the pointer and the keyboard share one highlight.
Motion
- The panel springs in (stiffness 520, damping 36) from scale 0.95 and 6px above; submenus come from 6px to the side. Items stagger in 28 ms apart after 30 ms, each sliding 8px on a spring (stiffness 520, damping 32).
- Close: the panel fades and shrinks to 0.97 in 120 ms.
- A highlight glides between items with a shared layoutId (stiffness 700, damping 45). Checkbox checks draw their stroke (pathLength spring 400 / 30); radio dots pop in (spring 600 / 26).
- Submenus open after 90 ms of hover and close 160 ms after the pointer leaves, so diagonal moves do not drop them.
- Reduced motion: panels and items only change opacity, instantly; the highlight, check and dot jump without animating.
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- Use onSelect, not onClick, on items. Call event.preventDefault() inside onSelect to keep the menu open.
- Checkbox items are controlled: always pass checked and onCheckedChange. Radio items only work inside DropdownStaggerRadioGroup with value and onValueChange.
- shortcut is display text only; bind the actual key yourself (for example with the kbd component's onTrigger).
- The menu is absolutely positioned inside an inline-block wrapper, with no portal and no vertical flip. Leave room below the trigger and avoid overflow: hidden on ancestors; use align="end" near the inline end (align is logical, so it mirrors under RTL).
- Keep item children as plain text where possible; typeahead matches on the item's text content.
FAQ
Where does a submenu open?
Toward the inline end (right in LTR, left in RTL) when there is room, else the other side, else below the item, measured each time it opens.
Can I control the open state?
Yes: pass open and onOpenChange. defaultOpen works for the uncontrolled case.
Related
Fully custom single or multi select: outline, filled, ghost, underline, pill and neo triggers; icons, avatars, descriptions and groups; search with match highlight, chips, clear, type-ahead and full keyboard; animated menu that flips up when space runs out.
A search field morphs into a command palette through a shared layout animation, with fuzzy matching, highlighted letters, grouped results, a sliding selection, arrow keys, Enter, Esc and Ctrl/Cmd+K.
A small button such as "Add note" morphs into a floating form panel, its label sliding into the header, and shrinks back into the button on save, Esc or an outside click.
Keyboard keys and combos in flat, raised 3D, outline, glass, neo and solid styles. Keycaps depress when the real keys are pressed, Mod maps to ⌘ or Ctrl, and onTrigger turns any combo or G-then-T sequence into a working hotkey.