Navigation
Blob Menu
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
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
openrecorded state; no setterselectedrecorded state; no setterreduced motionrecorded state; no settercompletedrecorded 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-opendefault-valueopenvaluewidth
Properties
onOpenChangeonSelectoptions
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.tsxsha256 1744f9065a8a…
Native files
The native module and its artifacts:
src/components/blob-menu/blob-menu.tssrc/components/blob-menu/styles.csssrc/components/blob-menu/define.tsdocs/components/blob-menu.mdsite/examples/blob-menu.tstests/browser/blob-menu.spec.tsevidence/components/blob-menu.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. |
default-value | defaultValue | string | options[0] | Kebab-case attribute; camelCase property. |
open | open | boolean | undefined | Presence semantics: present means true, absent means false. |
value | value | string | undefined | Kebab-case attribute; camelCase property. |
width | width | number | 306 | Kebab-case attribute; camelCase property. |
Properties
| Property | Type | Default | Notes |
|---|---|---|---|
onOpenChange | (open: boolean) => void | undefined | Property-only: assign from script; not accepted as an attribute. |
onSelect | (option: string, index: number) => void | undefined | Property-only: assign from script; not accepted as an attribute. |
options | string[] | ['Home', 'About', 'Projects', 'Contact', 'Book a call', 'Follow us'] | 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. |
detent-select | onSelect | (option: string, index: number) => void | Composed, 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.