Navigation
Dropdown
Origin-aware expansion
detent-dropdown
Port status
- Catalog status
- implemented
- Origin
- upstream
- Phase
- 3
- Wave
- phase-3-overlays
- Group
- Navigation
7.0 KB gzip for the class module · inlines the shared spring
Variants
slot="trigger")
Escape and outside presses close the innermost open dropdown and return focus to its trigger; Tab closes without
trapping. A disabled entry is skipped by roving focus and never activates. The menu is positioned against the host,
so an overflow: hidden ancestor clips it.
Recorded states
disabledrecorded state; no setteropenrecorded state; no setterclosedrecorded state; no setterexpandedrecorded state; no setterdark themerecorded state; no setter
Usage
Import
import '/components/dropdown/define.js';
// or register it yourself
import { DetentDropdown } from '/components/dropdown/dropdown.js';
Attributes
default-openopenplaceholderside
Properties
itemsonOpenChange
Slots
defaulttrigger
Events
detent-open-change— {}
Example
Runnable example: site/examples/dropdown.ts. The snippet below is generated from the catalogued attributes.
<detent-dropdown
default-open
open
placeholder
side="bottom"
></detent-dropdown>
Property-only values
const element = document.querySelector('detent-dropdown');
if (element !== null) {
// items: OriginDropdownItem[]
element.items = [];
// onOpenChange: (open: boolean) => void
element.onOpenChange = () => {};
}
Source
The pinned upstream source for this row:
src/components/ui/OriginDropdown.tsxsha256 aa41bea33849…
Native files
The native module and its artifacts:
src/components/dropdown/dropdown.tssrc/components/dropdown/styles.csssrc/components/dropdown/define.tsdocs/components/dropdown.mdsite/examples/dropdown.tstests/browser/dropdown.spec.tsevidence/components/dropdown.md
API
Boolean attributes follow HTML presence semantics: present means true, absent means false, and disabled="false" still means disabled.
Attributes
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
default-open | defaultOpen | boolean | false | Presence semantics: present means true, absent means false. |
open | open | boolean | undefined | Presence semantics: present means true, absent means false. |
placeholder | — | — | — | Recorded in the catalog as an attribute. |
side | side | 'top' | 'right' | 'bottom' | 'left' | 'bottom' | Kebab-case attribute; camelCase property. |
Properties
| Property | Type | Default | Notes |
|---|---|---|---|
items | OriginDropdownItem[] | undefined | Property-only: assign from script; not accepted as an attribute. |
onOpenChange | (open: boolean) => void | undefined | Property-only: assign from script; not accepted as an attribute. |
Events
| Event | Upstream prop | Payload type | Notes |
|---|---|---|---|
detent-open-change | onOpenChange | (open: boolean) => void | Composed, bubbles; fires only for a user action. |
Slots
| Slot | Notes |
|---|---|
default | Consumer content; rendered through a slot, never re-created. |
trigger | Consumer content; rendered through a slot, never re-created. |
CSS customisation
- React `children`/`ReactNode` props become DOM slots; caller content stays light DOM.
- React callback props become typed composed CustomEvents; programmatic state assignment emits none.
- Framer Motion springs/AnimatePresence ported to CSS transitions, WAAPI or the shared rAF spring helper.
- `lucide-react` icon components replaced with local SVG assets (ISC notice retained).
- React state/hooks replaced by element-owned state with pre-upgrade property preservation.
- Callback prop becomes an event; per-item callbacks stay data. onOpenChange → detent-open-change (detail { open }). A per-item onSelect stays an onSelect function on the property-only item object, so no unrecorded event is invented.
- icon is a local icon name. Upstream took a React.ReactNode; a node cannot cross into plain data (PC-6.3), so the native item takes one of the documented local glyph names (decorative SVG, PC-11.5). An unknown name renders no glyph.
- children becomes the default slot. Slotted content keeps its own semantics and listeners (PC-11.2/11.6) instead of being re-serialised.
- The slotted trigger is the trigger. Upstream wrapped trigger inside a generated button, which nests interactive elements in HTML; here the slotted element itself is the trigger and receives the ARIA relationship.
- Real roving focus. Focus moves to each enabled menuitem and the disabled ones are skipped (native disabled), instead of a highlight class on the container (PC-12.6).
- Framer Motion springSnappy becomes the shared spring helper with the pinned catalog token values (stiffness 380, damping 30, mass 0.5): one progress value drives opacity, scale and the per-side offset. The upstream exit animation is not reproduced — the menu closes immediately, which keeps aria-expanded and the visual state in step. Motion fidelity is a manual gate (PC-10.8) and is recorded as unresolved.
- Reduced motion jumps straight to the settled state: no spring, no frames, no inline styles (PC-10.5).
- Absolute positioning, not the top layer. The expansion originates from the trigger edge, which a popover panel cannot express while the trigger stays in flow. Clipping limitation: an overflow: hidden/clip ancestor clips the open menu; an open host raises itself above sibling components, but a sibling with a higher z-index still wins.
- Palette. Upstream --surface/--border/--text-primary tokens are mapped to --detent-color-*; text-rose-400 becomes a token-driven destructive tint and opacity-30 becomes the muted token at full opacity (PC-9.5).
- className is replaced by the documented parts and custom properties.
What it does
A trigger plus a menu that materialises from a named side (top, right, bottom, left) with a spring-driven scale/translate and a matching transform-origin, so the panel appears to grow out of the trigger edge closest to it.
Upstream behaviour
- Menu expansion originates from one of four sides (top/right/bottom/left)
- Snappy spring (springSnappy) with origin-based transform-origin per side
- Optional items array with icon, description, and destructive variants
- Chevron rotates 180° on open; Escape and outside-click close
- Light/dark theme aware via CSS variables
Differences from upstream
- React `children`/`ReactNode` props become DOM slots; caller content stays light DOM.
- React callback props become typed composed CustomEvents; programmatic state assignment emits none.
- Framer Motion springs/AnimatePresence ported to CSS transitions, WAAPI or the shared rAF spring helper.
- `lucide-react` icon components replaced with local SVG assets (ISC notice retained).
- React state/hooks replaced by element-owned state with pre-upgrade property preservation.
- Callback prop becomes an event; per-item callbacks stay data. onOpenChange → detent-open-change (detail { open }). A per-item onSelect stays an onSelect function on the property-only item object, so no unrecorded event is invented.
- icon is a local icon name. Upstream took a React.ReactNode; a node cannot cross into plain data (PC-6.3), so the native item takes one of the documented local glyph names (decorative SVG, PC-11.5). An unknown name renders no glyph.
- children becomes the default slot. Slotted content keeps its own semantics and listeners (PC-11.2/11.6) instead of being re-serialised.
- The slotted trigger is the trigger. Upstream wrapped trigger inside a generated button, which nests interactive elements in HTML; here the slotted element itself is the trigger and receives the ARIA relationship.
- Real roving focus. Focus moves to each enabled menuitem and the disabled ones are skipped (native disabled), instead of a highlight class on the container (PC-12.6).
- Framer Motion springSnappy becomes the shared spring helper with the pinned catalog token values (stiffness 380, damping 30, mass 0.5): one progress value drives opacity, scale and the per-side offset. The upstream exit animation is not reproduced — the menu closes immediately, which keeps aria-expanded and the visual state in step. Motion fidelity is a manual gate (PC-10.8) and is recorded as unresolved.
- Reduced motion jumps straight to the settled state: no spring, no frames, no inline styles (PC-10.5).
- Absolute positioning, not the top layer. The expansion originates from the trigger edge, which a popover panel cannot express while the trigger stays in flow. Clipping limitation: an overflow: hidden/clip ancestor clips the open menu; an open host raises itself above sibling components, but a sibling with a higher z-index still wins.
- Palette. Upstream --surface/--border/--text-primary tokens are mapped to --detent-color-*; text-rose-400 becomes a token-driven destructive tint and opacity-30 becomes the muted token at full opacity (PC-9.5).
- className is replaced by the documented parts and custom properties.