Detent web components
By Shane Logsdon

Navigation

Blob Menu

Implemented

Liquid gooey spring dropdown menu with SVG filter physics

detent-blob-menu

Port status
Catalog status
implemented
Origin
upstream
Phase
3
Wave
phase-3-overlays
Group
Navigation

8.5 KB gzip for the class module · inlines the shared spring

Variants

width="240"
open, preselected (value="Contact")

Arrow keys move roving focus, Enter/Space selects, Escape closes and returns focus to the trigger. Reduced motion removes the metaball filter and the movement. The panel is positioned against the host, so overflow: hidden ancestors clip it.

Recorded states

  • open recorded state; no setter
  • selected recorded state; no setter
  • reduced motion recorded state; no setter
  • completed recorded state; no setter

Usage

Import

import '/components/blob-menu/define.js';

// or register it yourself
import { DetentBlobMenu } from '/components/blob-menu/blob-menu.js';

Attributes

  • default-open
  • default-value
  • open
  • value
  • width

Properties

  • onOpenChange
  • onSelect
  • options

Slots

No slots are recorded for this component.

Events

  • detent-open-change — {}
  • detent-select — {}

Example

Runnable example: site/examples/blob-menu.ts. The snippet below is generated from the catalogued attributes.

<detent-blob-menu
  default-open
  default-value
  open
  value
  width="306"
></detent-blob-menu>

Property-only values

const element = document.querySelector('detent-blob-menu');
if (element !== null) {
  // options: string[]
  element.options = [];
  // onOpenChange: (open: boolean) => void
  element.onOpenChange = () => {};
  // onSelect: (option: string, index: number) => void
  element.onSelect = () => {};
}

Source

The pinned upstream source for this row:

  • src/components/ui/GooeyMenu.tsx sha256 1744f9065a8a…

Native files

The native module and its artifacts:

  • src/components/blob-menu/blob-menu.ts
  • src/components/blob-menu/styles.css
  • src/components/blob-menu/define.ts
  • docs/components/blob-menu.md
  • site/examples/blob-menu.ts
  • tests/browser/blob-menu.spec.ts
  • evidence/components/blob-menu.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.
default-valuedefaultValuestringoptions[0]Kebab-case attribute; camelCase property.
openopenbooleanundefinedPresence semantics: present means true, absent means false.
valuevaluestringundefinedKebab-case attribute; camelCase property.
widthwidthnumber306Kebab-case attribute; camelCase property.

Properties

Properties
PropertyTypeDefaultNotes
onOpenChange(open: boolean) => voidundefinedProperty-only: assign from script; not accepted as an attribute.
onSelect(option: string, index: number) => voidundefinedProperty-only: assign from script; not accepted as an attribute.
optionsstring[]['Home', 'About', 'Projects', 'Contact', 'Book a call', 'Follow us']Property-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.
detent-selectonSelect(option: string, index: number) => voidComposed, bubbles; fires only for a user action.

Slots

No slots are recorded for this component.

CSS customisation

  • `className` prop is replaced by documented CSS parts/custom properties; no Tailwind in consumers.
  • 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.
  • React state/hooks replaced by element-owned state with pre-upgrade property preservation.
  • Controlled/uncontrolled React pair maps to initial-value attributes plus owned state and change events.
  • Absolute positioning, not the top layer. The gooey surface is one filtered union of the pill and the panel, which a popover/<dialog> top-layer element cannot be while the pill stays in flow. Clipping limitation: the panel is positioned against the host, so a consumer that wraps the element in an overflow: hidden/clip ancestor clips the open panel. An open host raises itself with z-index: 1 over sibling components, but a sibling with a higher z-index still wins.
  • Callback props become events. onOpenChange → detent-open-change (detail { open }), onSelect → detent-select (detail { option, index }). Programmatic writes emit none.
  • Controlled/uncontrolled pairs map to owned, reflected open/value plus default-open/default-value for the initial value; there is no separate "controlled" mode.
  • Roving DOM focus replaces upstream's aria-activedescendant + activeIndex highlight; Tab closes without trapping (PC-12.6).
  • The drop, the rows and the chevron run upstream's springs through the shared spring helper: opening springs the drop's y on DROP_SPRING (240/17/1) and its scaleY on GROW_SPRING (210/22/1) after 50 ms, closing snaps both on SNAP_SPRING (240/20/0.9); rows enter (opacity 0, y -10) on ROW_SPRING (420/26/1) at 120 ms + index * 35 ms and, on close, leave on SNAP_SPRING 15 ms apart, last row first, while the listbox stays painted and inert; the chevron turns 180 degrees on its 300/20/1 spring. Not carried over: a default-open menu's rows appear at rest on first paint (upstream's AnimatePresence would enter them). Motion fidelity is a manual gate (PC-10.8).
  • Reduced motion removes the filter. Upstream disabled "intense blur filters"; here the whole metaball filter and every transition are removed, so the panel is a plain solid surface and opening is instant (PC-10.5).
  • Palette. The upstream dark-only hex palette is replaced by the inverted theme pair, so light and dark both work (PC-9.5). The trigger and the panel paint their own solid background over the blob, which keeps the label contrast measurable and the hit target real; the blob still supplies the gooey neck during the morph.
  • className is replaced by the documented parts and custom properties.

What it does

A pill trigger that opens a liquid dropdown: the pills are fused by an SVG metaball filter (feGaussianBlur → feColorMatrix alpha 22 -14 → feComposite atop), the panel morphs out of the pill, and the rows stagger in. Full listbox keyboard support, click-outside dismissal and reduced-motion handling.

Upstream behaviour
  • Real-time SVG color-matrix metaball fusion filter creates organic liquid stretch and tear dynamics
  • Choreographed staggered dropdown items with fast exit transitions
  • Complete keyboard accessibility (Arrow keys, Enter, Space, Escape) and ARIA listbox semantics
  • Click-outside listener and controlled/uncontrolled state support
  • Respects prefers-reduced-motion: automatically disables intense blur filters
Differences from upstream
  • `className` prop is replaced by documented CSS parts/custom properties; no Tailwind in consumers.
  • 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.
  • React state/hooks replaced by element-owned state with pre-upgrade property preservation.
  • Controlled/uncontrolled React pair maps to initial-value attributes plus owned state and change events.
  • Absolute positioning, not the top layer. The gooey surface is one filtered union of the pill and the panel, which a popover/<dialog> top-layer element cannot be while the pill stays in flow. Clipping limitation: the panel is positioned against the host, so a consumer that wraps the element in an overflow: hidden/clip ancestor clips the open panel. An open host raises itself with z-index: 1 over sibling components, but a sibling with a higher z-index still wins.
  • Callback props become events. onOpenChange → detent-open-change (detail { open }), onSelect → detent-select (detail { option, index }). Programmatic writes emit none.
  • Controlled/uncontrolled pairs map to owned, reflected open/value plus default-open/default-value for the initial value; there is no separate "controlled" mode.
  • Roving DOM focus replaces upstream's aria-activedescendant + activeIndex highlight; Tab closes without trapping (PC-12.6).
  • The drop, the rows and the chevron run upstream's springs through the shared spring helper: opening springs the drop's y on DROP_SPRING (240/17/1) and its scaleY on GROW_SPRING (210/22/1) after 50 ms, closing snaps both on SNAP_SPRING (240/20/0.9); rows enter (opacity 0, y -10) on ROW_SPRING (420/26/1) at 120 ms + index * 35 ms and, on close, leave on SNAP_SPRING 15 ms apart, last row first, while the listbox stays painted and inert; the chevron turns 180 degrees on its 300/20/1 spring. Not carried over: a default-open menu's rows appear at rest on first paint (upstream's AnimatePresence would enter them). Motion fidelity is a manual gate (PC-10.8).
  • Reduced motion removes the filter. Upstream disabled "intense blur filters"; here the whole metaball filter and every transition are removed, so the panel is a plain solid surface and opening is instant (PC-10.5).
  • Palette. The upstream dark-only hex palette is replaced by the inverted theme pair, so light and dark both work (PC-9.5). The trigger and the panel paint their own solid background over the blob, which keeps the label contrast measurable and the hit target real; the blob still supplies the gooey neck during the morph.
  • className is replaced by the documented parts and custom properties.
Previous: Glyph Matrix Next: Gravity Particle Burst

Docs

Components

Project