Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"

/** How the revealed panel is laid out — and with it, how the finger picks an item. */
export type PullMenuVariant = "rail" | "ladder" | "pad"

/** Where the gesture is. Mirrored onto the root as `data-phase`. */
export type PullMenuPhase = "idle" | "pull" | "run"

export interface PullMenuAction {
  key: string

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/pull-menu.json

Prompt

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-zoom leaves scrolling further into the list with the browser and hands downward drags over as plain pointer events, so nothing has to call preventDefault on a listener React registered as passive. The keyword names the scroll direction rather than the finger, so pan-down is the finger moving up and pan-up would 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, and overscroll-behavior-y: none switches 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 is inert and out of the accessibility tree; closing hands focus to a deliberate successor rather than to <body>; nothing ever takes the native disabled attribute; and the panel pads with env(safe-area-inset-*) because it opens straight into the notch.

On This Page