Pull Menu
An overscroll quick-action menu: pull a list past its top to reveal actions, the one under your thumb arms as you keep pulling or slide sideways, and releasing runs it.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/pull-menu.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "PullMenu" component — the mobile gesture
where overscrolling a list past its top reveals a strip of quick actions, and the
one under your thumb runs when you let go. React + lucide-react only: no gesture
library, no animation library.
Contract
- "use client". forwardRef<HTMLDivElement, PullMenuProps> extending
React.HTMLAttributes<HTMLDivElement>; the rest props spread onto the root.
- Props:
- actions: PullMenuAction[] — required, ordered as they are revealed (first in
the array is reached first). PullMenuAction = { key; label; icon: ReactNode;
hint?; destructive?; disabled?; onSelect?() }. `hint` is a second line that
only the ladder variant has room to render.
- variant: "rail" | "ladder" | "pad" = "rail" — one row of equal cells, a stack
of full-width rows, or a two-column grid.
- onAction?(key) — fires for every activation: released gesture, click, key.
- open? / defaultOpen = false / onOpenChange? — the latched menu, controlled OR
uncontrolled. The gesture deliberately does NOT latch: it opens the panel for
the duration of the pull and closes it on release.
- disabled = false — the overscroll is never claimed (native scrolling is left
completely alone) and the trigger reports aria-disabled.
- showTrigger = true, triggerLabel = "Actions", label = "Quick actions" (the
menu's accessible name), regionLabel = "Content" (the scroll region's).
- safeArea = true — pads the panel and the floating trigger with
env(safe-area-inset-top / right / left).
- labels?: Partial<{ hint; release; unavailable; done; empty }> for i18n.
- children: the scrollable content.
- Export the phase union "idle" | "pull" | "run" and mirror it, with the variant,
onto the root as data-phase / data-variant.
Behavior — claim narrowly, select by thumb, never latch by accident
- Structure: a clipping root (position relative, overflow hidden, its own height)
> an absolutely positioned panel pinned to the top edge > the scroll container
(overflow-y auto) holding children, plus a floating trigger above both. Pulling
translates the SCROLL CONTAINER down and grows the panel's height by the same
number of pixels, so the panel exactly fills the gap it opened. Transform the
container itself, never its contents — translating content inside a scroller
changes its scrollable overflow area and makes the scrollbar twitch.
- Negotiate the gesture in CSS instead of fighting the browser: while the scroller
is at scrollTop <= 0, set touch-action to "pan-x pan-down pinch-zoom", so
scrolling further into the list stays with the browser and a downward drag
arrives as plain pointer events. The keyword names the scroll direction, not the
finger — "pan-down" is the finger moving up, and "pan-up" would hand the pull
back to the browser while forbidding the scroll the list still needs, so a list
sitting at its top could not be scrolled by touch at all. Assign the plain
"pan-x pan-y pinch-zoom" first and the directional value second — a browser
without directional support rejects the second assignment and keeps the safe
one. Re-sync on every scroll
event. Nothing then needs preventDefault, so nothing needs a non-passive
listener. Also set overscroll-behavior-y: none, because this component draws its
own reveal and the platform bounce would fight it and chain to the page.
- Pointer Events only, one code path for finger, pen and mouse. Ignore
non-primary pointers and non-left mouse buttons. The drag is claimed on the
first movement past an 8px axis lock, and only if the scroller was genuinely at
the top when the pointer landed AND the drag is downwards AND more vertical than
horizontal. A drag that fails the test is dropped for the rest of the gesture,
never re-tested: a diagonal flick must not become a pull halfway through. On
claiming, re-baseline startY (so the pull starts at 0 rather than jumping the
lock distance) and call setPointerCapture on the element the gesture started on,
so a finger that wanders outside the box keeps delivering moves.
- Travel is 1:1 with the finger through the whole arming range, damped only past
the last item (0.3x, capped at 28px of overshoot). A refresh has one outcome and
can afford a rubber band; a selector cannot — if 100px of finger stops meaning
100px of travel, the item under the thumb stops being predictable.
- Arming is ONE rule for all three variants, read back off the DOM. Measure once
per gesture, on claim: every item's arm depth (its own top plus half its height,
relative to the panel content's top) and its centre x. The armed item is then
the deepest one the pull has reached, and among items within 6px of that depth,
the one nearest the finger's current x. A single row is decided purely by x, a
single column purely by depth, a grid by both — with no per-variant branch
anywhere, and with wrapped labels, custom row heights and the safe-area inset
all handled because they are measured, not assumed.
- The reveal is capped at min(natural panel height, max(48, rootHeight - 72)): the
panel never swallows the content it belongs to, and a short card still opens far
enough to show something.
- Release: the armed item runs, the panel freezes where the finger let go and
holds the result for 620ms (a tick replaces the item's icon, the status line
reads the label followed by "done"), then collapses. Released with nothing
armed, or cancelled by the system, it eases home and nothing runs.
- A disabled item still arms under the finger — what your thumb is over is what
you would get — and the release is refused out loud in the live region instead
of silently running the neighbour above it. A refusal must settle the panel;
never leave it parked open.
- The one-shot guarantee is a ref read AND written synchronously inside the
handler, never state: a second release during the hold must not run anything.
- The pull never latches the menu; the trigger and the keyboard do. Latched, the
panel is scrollable, its items are focusable, and a press anywhere outside it —
or Esc, or Tab, or running an item — dismisses it. While it is latched the
overscroll is not claimed at all: one open menu is enough.
- Edge cases: actions=[] renders no menu at all (the panel opens onto one line of
explanation and the trigger reports aria-disabled); a single action makes the
whole rail one target; labels longer than a cell truncate visually while the
full string stays the accessible name.
- Cleanup: the hold timer and the live-region timer are SEPARATE refs — sharing
one slot means a refusal spoken during the hold cancels the reset that ends it,
leaving the panel parked open with its one-shot guard still latched — and both
are cleared on unmount. The scroll listener and the outside-press listener are
removed by their own effects, and the outside-press listener only exists while
the menu is latched.
Rendering & styling
- Semantic tokens only, monochrome by default: root border + bg-card +
rounded-2xl, panel bg-muted, items rounded-lg border bg-card, status text
text-muted-foreground going text-foreground the moment something is armed. The
armed item INVERTS (bg-foreground text-background) rather than taking a colour;
a destructive action is the single exception (bg-destructive
text-destructive-foreground). A disabled item sits at opacity-40 and, when
armed, shows ring-2 ring-ring instead of the inverted fill — armed, but visibly
not the same thing.
- The moving pixels are written straight to the DOM (transform on the scroller,
height on the panel) from the move handler. Only the phase and the armed key
live in React state — a 60fps drag must not re-render the list, and both of
those change at discrete boundaries. Keep ONE writer: an effect paints the
resting height for every state the finger is not driving, and it runs after the
transition classes are back on the DOM, which is what makes the release ease
home instead of snapping. The run phase is deliberately excluded from that
effect, so the result is confirmed where the user was looking.
- The finger owns the whole surface during a pull: the panel is pointer-events
none while the phase is pull, so a release over an item arrives as the end of
the gesture and not as a click on that item. The click the browser synthesizes
after a claimed drag is swallowed by an onClickCapture guard, so a pull that
ends over a list row does not also open that row.
- Safe area: the panel content and the floating trigger both pad with
env(safe-area-inset-top / right / left). This panel lives under the notch — its
first row would otherwise sit behind the status bar, and the trigger under the
rounded corner.
- Touch: every item is at least 44px tall in its hit area, the trigger is a 44px
pill, and nothing depends on hover.
- Accessibility:
- The panel is role="menu" holding role="menuitem" buttons; the trigger carries
aria-haspopup="menu", aria-expanded and aria-controls pointing at the panel.
- Keyboard map: Tab reaches the trigger; Enter / Space / ArrowDown open the menu
and move focus to its first item; ArrowDown / ArrowRight and ArrowUp /
ArrowLeft walk the items (both pairs, because the same list is a row, a column
or a grid depending on the variant); Home / End jump to the ends; Enter /
Space run the focused item; Esc and Tab close the menu and hand focus back to
the trigger. Escape is handled on the panel with stopPropagation, never on a
window listener — one Escape inside a dialog must close one layer, not both.
Inside the scroll region every native scroll key keeps working untouched.
- Closed, the panel is inert + aria-hidden: not a tab stop, not in the
accessibility tree, not reachable by a screen reader's virtual cursor.
- Nothing ever takes the native disabled attribute — the browser blurs a node
the instant it becomes disabled, so a control that disables itself drops focus
onto <body>. aria-disabled plus a guard in the handler, everywhere.
- Closing hands focus to a deliberate successor (the trigger, or the scroll
region when there is no trigger) and only when focus was inside the panel, so
it never steals focus from elsewhere on the page.
- A polite sr-only role="status" announces outcomes and refusals only, never the
drag: announcing every armed item would be a screen-reader storm, and the
gesture is not the route assistive touch takes — the latched menu is. Clear
the string afterwards so the next identical result is announced again.
- prefers-reduced-motion: every transition is dropped via motion-reduce
utilities. The pull still follows the finger — that is direct manipulation,
not decoration — and the arming, the status line and the announcement are
unchanged, so the feature works identically with the animation off.
Customization levers
- variant is the layout axis and, because arming is measured off the DOM, the
selection model too: rail = picked by x (2-5 actions), ladder = picked by depth
(labels plus hints), pad = both (up to about 6). A fourth layout needs no
selection code at all — give the items new classes and keep data-pull-item on
each one.
- Feel lives in three constants: the arm fraction (how much of an item must be
revealed before it arms; 0.5 is "half way", raise it to make a pick more
deliberate), the 8px axis lock, and the overshoot pair (28px / 0.3).
- The reveal cap decides how much content stays visible under the panel; the
reveal floor keeps a short card usable. Raise the cap for a full-screen picker,
lower it when the list underneath has to stay readable.
- labels is the i18n seam; nothing else in the component contains prose.
- Height belongs to the consumer: className="h-96" or "h-full" merges last through
cn(). Give the root a height, or the inner scroller has no range to scroll.
- Skin by state rather than by forking the markup: data-phase on the root,
data-armed on the armed item, data-slot="pull-menu-panel" on the panel.
- showTrigger={false} when you already have a header action wired to the same open
state. Keep exactly one of the two and never zero — a touch-only menu is
unreachable by keyboard.
- A consumer-owned trigger must SET the open state, never toggle it: the
outside-press dismisser has already closed the menu by the time that click
lands, so a toggle reads the wrong state and reopens what it meant to close.
- Async work belongs in your onAction, with your own progress UI. This component
confirms the choice for 620ms and deliberately owns no async lifecycle; if you
need "held open until the promise settles", that is pull-to-refresh's job.
- safeArea={false} when the component is embedded in a device frame or a card that
already handles the inset.Concepts
- Overscroll as a picker, not as a refresh — the same claimed gesture, a different contract. A refresh has exactly one outcome, so it can spend the pull on rubber-band physics and hold the panel open until a promise settles. A menu has N outcomes, so every pixel of travel has to stay legible as "which one am I on": travel is 1:1 with the finger through the whole arming range and damped only past the last item, and the panel exists only long enough to be picked from.
- Selection follows the thumb — the panel opens at the top of the screen, which on a phone is the one place a thumb cannot reach. So the armed item is never chosen by touching it: it is chosen by how far the finger has pulled and where sideways it already is. The whole menu is used without the hand moving up, which is the entire reason this control exists on a phone and not on a desktop.
- One arming rule, three layouts — each item's arm depth (its own top plus half its height) and centre x are measured off the DOM once per gesture; the armed item is the deepest one the pull has reached, and among items within 6px of that depth, the nearest to the finger's x. A row is then decided purely by x, a column purely by depth, a grid by both, with no per-variant branch — and wrapped labels, custom row heights and the safe-area inset come out right for free, because they were measured rather than assumed.
- Negotiated in CSS, not by fighting the browser — while the scroller is at the top,
touch-action: pan-x pan-down pinch-zoomleaves scrolling further into the list with the browser and hands downward drags over as plain pointer events, so nothing has to callpreventDefaulton a listener React registered as passive. The keyword names the scroll direction rather than the finger, sopan-downis the finger moving up andpan-upwould have blocked the only scroll a list at its top can still do. The directional value is assigned after the plain one, so a browser that does not support it silently keeps the safe fallback, andoverscroll-behavior-y: noneswitches off the platform bounce that would otherwise fight the reveal being drawn here. - Armed is a state, not a prediction — the item inverts and the status line names it before the finger lifts, so the user knows what letting go will do. A disabled item still arms under the thumb, because hiding the arm would silently run its neighbour instead; the release is refused out loud in a polite live region, and the panel settles either way. A refusal that left the panel parked open would be the worst bug this component could ship.
- The gesture is a shortcut, never the only route — the same menu latches open from a real focusable trigger with
aria-haspopup="menu", roving arrow keys, Home / End, Enter / Space and Escape; closed, the panel isinertand out of the accessibility tree; closing hands focus to a deliberate successor rather than to<body>; nothing ever takes the nativedisabledattribute; and the panel pads withenv(safe-area-inset-*)because it opens straight into the notch.
Sticky Section List
A grouped mobile list whose section headers pin to the safe-area line and push each other out, with a thumb-arc pager that walks the sections and reports the one being read.
Map Bottom Sheet
A map with a draggable sheet over it: detents, velocity settling, and a map that lifts its focal point by half of whatever the sheet covers.