Stagger dropdown

Free · MIT

A 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.

MarkdownOpen in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/dropdown-stagger.json

The 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

Use it for
  • 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.
Reach for something else
  • 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 needUse
Run an actionDropdownStaggerItem with onSelect (closes the menu)
Delete, cancel or removeDropdownStaggerItem variant="destructive", after a separator
Toggle several options without closingDropdownStaggerCheckboxItem (closeOnSelect defaults to false)
Choose one option such as a sort orderDropdownStaggerRadioGroup with DropdownStaggerRadioItem
A second level, such as time slotsDropdownStaggerSub
Icon-only triggertriggerLabel 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

PropTypeDefault
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.
booleanfalse
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

PropTypeDefault
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.
booleantrue
className
Classes merged onto the root element.
string—

DropdownStaggerCheckboxItem

PropTypeDefault
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.
booleanfalse

DropdownStaggerRadioGroup

PropTypeDefault
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

PropTypeDefault
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.
booleanfalse

DropdownStaggerSub

PropTypeDefault
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

PropTypeDefault
children*
Content.
React.ReactNode—
idstring—
className
Classes merged onto the root element.
string—

DropdownStaggerSeparator

PropTypeDefault
className
Classes merged onto the root element.
string—

Accessibility

Enter / Space / ArrowDownOn the trigger: opens the menu and focuses the first item.
ArrowUpOn the trigger: opens the menu and focuses the last item.
ArrowDown / ArrowUpMoves to the next or previous enabled item, wrapping at the ends.
Home / EndMoves to the first or last enabled item.
Enter / SpaceActivates the focused item, toggles a checkbox item or picks a radio item.
ArrowRight / Enter / SpaceOn a submenu item: opens the submenu and focuses its first item.
ArrowLeftInside a submenu: closes it and returns to its parent item.
EscCloses the submenu you are in, or the menu, returning focus to its trigger.
TabCloses the whole menu and returns focus to the trigger.
LettersTypeahead: 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