useMenuPress@astryxdesign/core v0.7.5 · useMenuPress

Usage

The press model of macOS and iOS menus, for any pointer: the row under the pointer when it is RELEASED is the row that acts, the highlight follows the pointer while it is held, a mouse opens the menu on press and can drag straight into it, and a finger held on the trigger opens it with the finger still down. The pointer is tracked at document level by pointerId, so a finger that slid off the row it landed on is still followed; the click the browser reports at the end of a touch, aimed at the row where the touch began, is swallowed so nothing acts twice. DropdownMenu, ContextMenu, DropdownMenuSubMenu, Selector and the menu bottom sheet already mount it; reach for it directly only when building a menu-like surface of your own.

ts
import {useMenuPress} from '@astryxdesign/core/hooks'

Best practices

GuidancePractices
Do

Pass a selector for ENABLED rows only, so a disabled row or a divider under the pointer clears the highlight instead of lighting up.

Do

Let the default highlight move focus in a menu; supply onHighlight only for a listbox that must keep focus on its combobox and highlight through aria-activedescendant.

Do

Declare touch-action on the menu root: pinch-zoom when its rows fit, pan-y pinch-zoom when it scrolls, so the browser — not the hook — decides when a finger is scrolling, and a two-finger pinch still zooms the page.

Don't

Act on a row from its own pointerdown or pointerup handler as well; the hook already activates the row under the release, and a second path acts twice.

Don't

Read a row click with detail 0 as a keyboard activation; the hook dispatches its pointer activation with detail 0 too. Use isMenuPressActivation() to tell them apart.

Parameters

ParamTypeDescription
optionsrequired

Configuration object.

options.menuRefrequired
RefObject<HTMLElement | null>

The menu or listbox root — the surface whose rows a press picks from.

options.itemSelectorrequired
string

Selector matching the ENABLED rows. A pointer over anything else inside the menu (a divider, a heading, a disabled row) highlights nothing.

options.triggerRef
RefObject<HTMLElement | null>

The control that opens the menu, when a press may start there.

options.onTriggerPress
(pointerType:
) => boolean

A mouse pressed the trigger, a finger did with touchOpen 'press', or a finger rested on it for the long-press delay: open the menu under the held pointer and return whether it opened. Return false when the press closed an open menu instead.

options.onHighlight
(row: HTMLElement | null) => void

Move the highlight; null clears it. Defaults to moving DOM focus with preventScroll onto the row, and onto the menu root when there is no row. A picker that highlights through aria-activedescendant supplies its own.

options.onActivate
(row: HTMLElement, release: PointerEvent) => void

Act on the row under the release. Defaults to dispatching a click on the row that carries the release button and modifier keys.

options.onDismiss
() => void

Close the menu: a MOUSE was released outside it with nothing acting, or the browser took a finger's press-open (touchOpen 'press') for a scroll or a pinch before the finger reached the menu. A finger released outside leaves the menu open, so this is never called then.

options.getScroller
() => HTMLElement | null

The element to scroll while a tracked pointer rests near its top or bottom edge. Defaults to the menu root when it overflows.

options.longPressDelayMs
number (default: 150)

How long a finger must rest on the trigger before the menu opens under it: UIKit’s delay for a pull-down button. Inside a scrolling region the scroll view’s content-touch delay (150 ms) comes first. A tap released sooner opens the menu through the trigger’s click.

options.touchOpen
'tap' | 'press' (default: 'tap')

How a finger opens the menu from its trigger. 'tap': through the trigger's own click when the finger lifts, or with the finger down after the long-press delay. 'press': at once on press-down, as a mouse press does, for a trigger that is not inside a scrolling region; the press then continues as a drag onto a row, which picks it only when the trigger declares touch-action: none or pinch-zoom. The page is not held still: if the browser takes the press for a scroll or a pinch before the finger reaches the menu, the menu closes again through onDismiss.

options.isEnabled
boolean (default: true)

Whether the model is live.

Returns

FieldTypeDescription
menuProps{onPointerDown; "data-astryx-menu-press": ""}

Spread onto the menu root. Claims presses that begin inside it and marks the root as carrying the press model.

triggerProps{onPointerDown; onContextMenu}

Spread onto the trigger: a mouse press opens the menu at once; a finger held for the delay opens it with the finger still down.

isTriggerClickFromPress() => boolean

Whether the click reaching the trigger belongs to the gesture that just pressed it. That press already opened or closed the menu, so the click must neither toggle nor reopen.

cancel() => void

End the gesture in flight with nothing acting, for when the menu closes under it.

continueGesture(pointer: {pointerId: number; pointerType: 'mouse' | 'touch' | 'pen'; x: number; y: number; time: number}) => void

Continue a pointer that went down elsewhere (a context menu’s long press) into the menu that just opened under it. It is tracked as the opening press: a finger picks only after it travels 10 px from the first sample after this call, as in UIMenu; a mouse follows the settle rule. Ignored while another gesture is live.

Use with shadcn

Already using the shadcn registry workflow? Install the real Astryx package and a local public re-export. Component implementation source stays in Astryx.

This install URL expires with the draft preview.
bash
npx shadcn@4.19.0 add https://astryx-l26en3qbz-fbopensource.vercel.app/shadcn/hooks/use-menu-press.json