Detent web components
By Shane Logsdon

Navigation

Dropdown

Implemented

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

the other three sides, item variants, disabled entry
slotted trigger (slot="trigger") Account

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

  • disabled recorded state; no setter
  • open recorded state; no setter
  • closed recorded state; no setter
  • expanded recorded state; no setter
  • dark theme recorded state; no setter

Usage

Import

import '/components/dropdown/define.js';

// or register it yourself
import { DetentDropdown } from '/components/dropdown/dropdown.js';

Attributes

  • default-open
  • open
  • placeholder
  • side

Properties

  • items
  • onOpenChange

Slots

  • default
  • trigger

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.tsx sha256 aa41bea33849…

Native files

The native module and its artifacts:

  • src/components/dropdown/dropdown.ts
  • src/components/dropdown/styles.css
  • src/components/dropdown/define.ts
  • docs/components/dropdown.md
  • site/examples/dropdown.ts
  • tests/browser/dropdown.spec.ts
  • evidence/components/dropdown.md

API

Boolean attributes follow HTML presence semantics: present means true, absent means false, and disabled="false" still means disabled.

Attributes

Attributes
AttributePropertyTypeDefaultNotes
default-opendefaultOpenbooleanfalsePresence semantics: present means true, absent means false.
openopenbooleanundefinedPresence semantics: present means true, absent means false.
placeholder———Recorded in the catalog as an attribute.
sideside'top' | 'right' | 'bottom' | 'left''bottom'Kebab-case attribute; camelCase property.

Properties

Properties
PropertyTypeDefaultNotes
itemsOriginDropdownItem[]undefinedProperty-only: assign from script; not accepted as an attribute.
onOpenChange(open: boolean) => voidundefinedProperty-only: assign from script; not accepted as an attribute.

Events

Events
EventUpstream propPayload typeNotes
detent-open-changeonOpenChange(open: boolean) => voidComposed, bubbles; fires only for a user action.

Slots

Slots
SlotNotes
defaultConsumer content; rendered through a slot, never re-created.
triggerConsumer 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.
Previous: Orbital Loader Next: OTP Input

Docs

Components

Project