A positioning primitive built on floating-ui, not a menu component. It floats arbitrary caller JSX near a trigger button, and every menu-shaped surface in the kit (Select, RowMenu, WorkspaceSwitcher) composes on top of it rather than duplicating its floating and dismiss logic.
The canon behind it. docs/design-language/10-overlays.md §A7 (10-overlays.md:39, Dropdown is positioning, not a menu, and content is caller JSX) and docs/design-language/00-overview.md model 3 (00-overview.md:68, the kit is generic, the app supplies meaning, which is why Dropdown never interprets its children).
When to reach for it#
Reach for Dropdown when you need a panel of your own content positioned relative to a trigger and you are willing to supply that content yourself: a filter panel, a custom picker, a menu with mixed content. It handles placement, flip and shift, click-outside dismissal, and the open and close transition. It does not manage selection state, keyboard list navigation, or menu semantics on its own.
For the common menu shapes, prefer the kit component built on Dropdown instead of composing Dropdown directly:
- A row's trailing kebab menu:
RowMenu(table/row-menu.tsx:49), not a hand-rolledDropdownwith a list inside. - A single or multi select control:
Select(select/select.tsx:361). - The workspace picker in the sidebar:
WorkspaceSwitcher(identity/workspace-switcher.tsx:36).
Reach for raw Dropdown when the panel's content genuinely does not fit those shapes, for example a filter bar's per-field panel (speedway/app/components/FilterBar.tsx:207) or an account menu with a custom footer (speedway/app/components/AccountMenu.tsx:42).
Contract#
button (required) is the trigger element. It is cloned to receive the floating ref and whichever event's handlers are active, so it must be a single element that accepts a ref and forwards standard DOM event props.
children (required) is the floating panel's content. It is entirely caller JSX; Dropdown does not interpret it.
event selects the opening gesture: "click" (default), "hover", "right-click", "double-click". Only the matching interaction hook is enabled (dropdown.tsx:147-152). No real usage of "hover", "right-click", or "double-click" was found in either app as of this sweep; every shipped Dropdown opens on click.
placement (floating-ui Placement, default "bottom-start") and offset (pixels, default 8) control positioning. middlewareProps is the raw floating-ui escape hatch for flip, shift, arrow, and the base position options, for anything not exposed as a named prop.
open/setOpen make it a controlled component; omit both for internal uncontrolled state. FacetToolbar and AccountMenu control it externally so a parent can also close the panel on selection (walmart-mvp/frontend/src/components/FacetToolbar.tsx:71-74).
noPortal renders the panel in place instead of through the shared floating portal, for nesting a dropdown inside other portaled content such as a modal. Select and Tooltip both thread a noPortal prop through to their internal use of Dropdown or the portal registry, but no app call site sets it to true as of this sweep.
closeOnEscape (default true) and disableCloseOnClickAway (default false) tune dismissal. arrow and arrowSize draw the floating pointer back to the trigger.
matchTriggerWidth sizes the panel to exactly the trigger's width, for a menu sitting under a full-width control. Real usage: the sidebar's org picker (walmart-mvp/frontend/src/App.tsx:220, comment: "matchTriggerWidth copies this button's width onto the panel, so the floor is what stops a 64px mobile rail collapsing the menu to unreadable") and WorkspaceSwitcher's conditional use when it opens downward (identity/workspace-switcher.tsx:40).
Slot class names (className, panelClassName, arrowClassName) target the position wrapper, the animated content box, and the pointer respectively. panelProps is a raw DOM attributes passthrough for the panel element, excluding className.
Sanctioned combinations#
| Combination | What it produces | Where used | Why |
|---|---|---|---|
Dropdown with a custom filter panel as children, placement="bottom-start" | A per-field filter popover in a toolbar | speedway/app/components/FilterBar.tsx:207-215 | The panel's content (a value picker specific to the filter's type) does not fit Select's single-value or RowMenu's action-list shape. |
Dropdown open={open} setOpen={setOpen} (controlled) with a facet picker panel | A multi-select facet control that can also be closed programmatically on apply | walmart-mvp/frontend/src/components/FacetToolbar.tsx:71-74 | The panel needs to close itself on an internal "apply" action, which an uncontrolled Dropdown cannot trigger from outside. |
Dropdown matchTriggerWidth under a full-width trigger button | A menu that never appears narrower than the control it hangs from | walmart-mvp/frontend/src/App.tsx:204-220 | Prevents the panel collapsing to unreadable under a narrow collapsed sidebar rail. |
RowMenu (built on Dropdown internally) for a table row's actions | The trailing kebab menu | table/row-menu.tsx:49-94 | Inherits Dropdown's floating and dismiss behavior "rather than a bespoke menu engine" (source comment, table/row-menu.tsx:32-36), and stops the trigger's click from also selecting or expanding the row. |
Banned combinations#
Do not compose a new Dropdown plus a hand-rolled <ul> of clickable rows when RowMenu or Select already cover the shape. The wrong answer usually arrives as daisyUI's copy-paste idiom, a <label tabIndex={0}> trigger over a <ul className="dropdown-content menu">; this Dropdown is a floating-ui portal primitive, not that markup (dropdown.tsx:79), and the row semantics the idiom hand-rolls are exactly what RowMenu and Select already own. The one sanctioned hand-rolled listbox in the kit exists only because a native <select> "fires nothing on a same-value pick" (10-overlays.md §A7); that is a documented, narrow exception, not a general license to rebuild menu semantics on raw Dropdown.
Do not rely on middlewareProps to override flip or shift defaults casually. It is the escape hatch for cases the named props do not cover, and no shipped call site in either app uses it, which means any use is currently unverified against the kit's own positioning defaults.
Do not nest a portaled Dropdown inside a Modal without noPortal. The panel would portal to the shared dropdown root outside the dialog's top layer and could render behind or detached from the modal it opens from; noPortal is the documented fix but has no shipped example to point to as of this sweep.
Before you adopt this#
Five questions to answer before reaching for Dropdown.
- Does the shell, a parent layout, or a global provider already render this? Not applicable, Dropdown is a positioning primitive with no shell-level twin.
- Does this app already ship a local implementation of the same thing?
RowMenuorSelectalready covers a hand-rolled<ul>of clickable rows. - Does this app's kit pin reach the version this component or prop landed in? Not applicable, no version-landed Dropdown prop is noted in this doc.
- Does the component derive its own accessible name and keyboard path, or must the call site supply them? The caller's own
buttontrigger must carry its accessible name; Dropdown adds none. - Which canon §A rules bind this surface, and which does the composition break? §A7 makes Dropdown positioning only; content and semantics are the caller's.
Travels with#
Button triggers are common but not required; the trigger can be any single cloned element, including a plain <button> as in AccountMenu and FacetToolbar.
Select and RowMenu and WorkspaceSwitcher are all built on Dropdown internally (select/select.tsx:361, table/row-menu.tsx:49, identity/workspace-switcher.tsx:36). Reach for one of them before composing Dropdown directly for a menu or single-value picker shape.
Position and the exit animation live on separate nested divs inside the panel, because floating-ui positions via an inline transform, which would silently block a class-based scale or translate on the same element (dropdown.tsx:80-85). This is internal to the component; callers do not need to replicate it, but panelClassName targets the inner animated div, not the outer position wrapper that className targets.
Snippet#
// walmart-mvp/frontend/src/App.tsx:204-220 (trimmed)<Dropdown button={ <button type="button" className="w-full min-w-44 cursor-pointer rounded-selector text-left"> <IdentityRow avatar={(user.full_name || user.email).slice(0, 2).toUpperCase()} title={user.full_name || user.email} subtitle={activeOrg.role} /> </button> } matchTriggerWidth> {/* org switcher panel content */}</Dropdown>