Context Peek
The long-press peek: the held target lifts out of a blurred page and its actions resolve under the finger, as a menu, an icon toolbar or an expanded preview card.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/context-peek.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ContextPeek" component — the mobile
long-press peek: hold a piece of content, it lifts out of the page while
everything behind it blurs, and its actions resolve right under the finger.
React + react-dom (createPortal) only: no gesture library, no positioning
library, no animation library.
Contract
- "use client". forwardRef<HTMLButtonElement, ContextPeekProps> extending
Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children" | "onChange">;
the rest props spread onto the trigger. The trigger IS a real <button
type="button">, so keep children presentational.
- Props:
- actions: ContextPeekAction[] — { key, label, icon?, hint?, destructive?,
disabled?, onSelect? }. An EMPTY list is a refusal: the peek never opens and
the trigger drops aria-haspopup, rather than opening an empty menu.
- children — the peekable content. It is rendered twice while lifted (once in
place, once in the floating clone), so it must be presentational: no ids that
must stay unique, no components that own a subscription.
- variant = "menu" | "bar" | "preview" (default "menu"). "menu" is a vertical
list beside the lifted target; "bar" is a compact icon toolbar for grid tiles
that do not want a 208px column hanging off them; "preview" is one card — an
enlarged panel (the `peek` prop) with the actions attached along its bottom
edge, gap 0, shared border.
- open? / defaultOpen = false / onOpenChange? — controlled and uncontrolled
both supported. A controlled parent that refuses to close leaves the peek up.
- onAction?: (key: string) => void, fired alongside the action's own onSelect.
- onTap?: () => void — the plain tap, fired by Enter and Space as well so it is
never pointer-only. Omitted, a tap opens the peek, so a pointer that cannot
hold is never left at a dead end.
- delay = 420 (clamped 120..2000), moveTolerance = 10 (clamped 2..64).
- peek?: ReactNode — replaces the clone in variant="preview".
- label? — the menu's accessible name, and its heading in variant="menu".
- Clamp every consumer number through one helper that rejects NaN: a NaN delay
is a hold that never fires.
Behavior — one continuous press, and never at the cost of a scroll
- Press: pointerdown records { pointerId, x, y }, takes setPointerCapture on the
trigger (the element that started the gesture) and arms a `delay` timer. Only
the left button counts for a mouse. The trigger scales to 0.98 while pressing —
the affordance that says the hold is being counted.
- Cancel: movement past moveTolerance before the timer fires abandons the press
for good, so a flick that starts on a peekable row still scrolls the list.
pointercancel (the browser taking the gesture for a scroll) does the same.
- Open: the timer lifts the target. touch-action stays pan-y on the trigger so
the list keeps scrolling; only AFTER the peek is open does a native touchmove
listener registered with { passive: false } call preventDefault — guarded by
event.cancelable — so the finger can slide onto an action instead of scrolling
the page behind the overlay. React registers onTouchMove passively and a
passive listener may not cancel, which is why that one listener is native.
- Slide to choose: while the peek is open and the finger is still down, every
move hit-tests document.elementFromPoint and looks for the closest
[data-peek-index]. Hit-testing, not pointerenter on each row: pointer capture
means those events are being delivered to the trigger, not to the item under
the finger. Disabled items never take the highlight.
- Release: over an item it fires; anywhere else the menu deliberately stays up
and becomes an ordinary tap-to-choose menu. Both cases mark the click that the
browser synthesises afterwards as suppressed, consumed once by a capture-phase
guard on the trigger and on the overlay root — a released hold must never also
count as a tap.
- The one-shot guarantee is a ref read AND written synchronously inside the
handler, cleared by the next pointerdown anywhere: a double tap, and the
release-then-click pair, must fire exactly one action.
- contextmenu (right click, the ContextMenu key, Shift+F10) is preventDefault-ed
and opens the same peek — which also swallows the native long-press menu
Android raises at ~500ms on top of everything. When that one lands the peek is
already open under a live finger, so the handler returns instead of ending the
press: cancelling there would freeze the highlight halfway through the gesture.
- Close: Esc, a tap on the backdrop, or firing an action. Focus is handed back to
the trigger, and only if it is still connected — a row often re-renders away
with its list, and focus() on a detached node drops focus onto <body>.
Placement — anchored to the source rect, inside the safe area
- Structure: a portal to document.body > a fixed inset-0 layer > the blurred
backdrop + a "safe box" (absolute, inset-x-0, padded by
env(safe-area-inset-*) + 12px, top/height driven by window.visualViewport) >
a relative frame > the absolutely positioned column [clone, menu].
- The frame is the real estate a peek may use. Every number is measured relative
to it, so the notch and the home indicator are handled by layout instead of by
parsing env() in JavaScript, and the visual viewport keeps the menu above the
software keyboard (the layout viewport still claims the full height while the
keyboard is up).
- Geometry is written straight to the DOM in a layout effect (before paint, so
nothing flashes at a provisional position) and re-run, rAF-coalesced, on
resize / scroll / visualViewport changes. None of it goes through state: a
re-render per scroll frame buys nothing.
- Order inside that pass matters:
1. widths first — the menu wraps its labels against its final width, so a
height measured before that is a lie. menu width = max(target width, 208)
for "menu"/"preview"; for "bar" only a maxWidth cap is written and the
natural width is measured, because writing back a measured width would
shrink the box by its borders on every pass.
2. clear both maxHeights, measure, then re-apply — otherwise a rotation into a
taller viewport stays stuck at the old cap. Save and restore the menu's
scrollTop across that, or a long menu jumps back to the top.
3. the menu is served first (it is the actionable half) and the clone absorbs
what is left, never below 48px; the two caps together can never exceed the
frame, so the peek always fits.
- Vertical: the menu goes below the target; if that overflows, the column flips
so the clone's BOTTOM stays on the target's bottom edge (flex-direction:
column-reverse, DOM order unchanged, so reading order never depends on which
side won); if neither fits, it is clamped inside the frame. "preview" never
flips — its two halves share one border and one pair of rounded corners, so
reversing them would hang the card's seam on the outside — it slides up instead.
- Horizontal: a target whose centre is in the right half hangs the column off its
own right edge (align-items: flex-end) — the edge the thumb is already nearest
— otherwise off the left edge, then clamped into the frame. transform-origin is
set to the same corner so the lift animation grows out of the target.
- Scroll events originating INSIDE the peek are ignored: the anchor has not
moved, and re-measuring would fight the reader.
Rendering & styling
- Semantic tokens only, monochrome first: backdrop bg-background/70 +
backdrop-blur-md; clone and menu bg-card + border + shadow-lg + rounded-2xl
(rounded-lg for rows, per the 16/12/8 radius ladder); labels text-foreground,
hints text-muted-foreground. The HIGHLIGHTED row inverts —
bg-foreground text-background — rather than taking a colour, because the finger
is on top of it and it has to survive being half covered. Colour is reserved
for one thing: destructive actions are text-destructive, and
bg-destructive/text-destructive-foreground while highlighted.
- The original stays in the layout at opacity 0 while lifted, so nothing reflows;
the floating clone is aria-hidden + inert so its contents are neither announced
twice nor focusable.
- Motion ships as one injected <style href> sheet (React 19 hoists and de-dupes
it by href): a 160ms backdrop fade and a 220ms scale-from-0.94 lift, both
switched off inside a prefers-reduced-motion media query. The resting style is
the final state, so reduced motion leaves the peek fully working, just
arriving instantly. The press-in scale uses motion-reduce:transition-none.
- Every hit area is at least 44px: menu rows min-h-11, toolbar buttons
min-h-14 min-w-14. Long labels WRAP (break-words) instead of truncating — a
menu that silently hides half an action is worse than a two-line row.
- Accessibility:
- Trigger: a real <button type="button"> with aria-haspopup="menu",
aria-expanded, aria-controls while open, and aria-describedby pointing at a
hidden sibling ("Press and hold, or press Enter, for N actions" — Arrow Down
instead of Enter when onTap has claimed it). Hidden text is still exposed
through aria-describedby, and keeping it out of the button leaves the
accessible name to the content.
- Menu: role="menu" + aria-label, focused on open (tabIndex -1), items
role="menuitem" tabIndex -1, highlight published with aria-activedescendant.
One index serves the finger and the arrow keys, so they can never disagree.
- Keyboard map: Enter / Space / ArrowDown open with the first action
highlighted, ArrowUp opens with the last, the context-menu key opens
immediately — but a trigger that was given onTap leaves Enter / Space to that
tap and opens on the arrows and the context-menu key instead, so the primary
action is never pointer-only; inside, ArrowDown/ArrowRight and
ArrowUp/ArrowLeft move (wrapping, skipping disabled), Home / End jump, Enter /
Space fire, Esc closes
(stopPropagation, so nested overlays do not all close at once), Tab closes
WITHOUT preventDefault so focus lands back on the trigger and the browser's
own Tab carries on from there.
- Disabled actions use aria-disabled plus a guard in the handler, never the
native disabled attribute — the browser blurs a node the instant it becomes
disabled, and the user may be standing on it.
- Cleanup: the hold timer is cleared on unmount and at every end of press, the
rAF is cancelled, and the resize / scroll / visualViewport / touchmove
listeners are removed with their effect.
Customization levers
- Feel lives in two numbers: delay (how long is "on purpose" — 420 is the phone
default, 250 for a photo grid where nothing is being scrolled past, 600 for a
destructive-heavy menu) and moveTolerance (raise it to 16 on a jittery list,
drop it to 6 for a target inside a carousel).
- variant is the presentation axis and nothing else changes: "menu" for text
rows, "bar" for square media tiles, "preview" when the peek itself carries the
information (a link card, an unread message, a contact).
- GAP (8), MIN_MENU_WIDTH (208), MIN_PEEK_WIDTH (280), MIN_PREVIEW_HEIGHT (48)
are the four layout constants; widen MIN_MENU_WIDTH for a two-line row style,
lower MIN_PREVIEW_HEIGHT if you would rather crop the clone than the menu.
- Skin it without touching the logic: the trigger takes className through cn(),
and carries data-state="open" | "closed" for a pressed treatment of your own.
- Swap the invert for a soft highlight (bg-accent text-accent-foreground) if your
brand cannot carry a black row; keep destructive on its own token either way.
- The clone is a copy of children by design. Pass `peek` to show something else
entirely — the full text of a truncated message, a bigger crop, a map — and
keep it a visual enlargement of information already on screen, because it is
aria-hidden.
- To add a reaction row above the actions, render it as the first child of the
menu; the placement maths measures whatever is in there.Concepts
- Lift and blur — a peek does not navigate anywhere: the target is cloned into a fixed overlay at exactly its own rect while the original stays in the layout at zero opacity, and everything else is pushed behind
backdrop-blur. Nothing reflows, the user never loses their place, and the menu is unmistakably about that thing rather than about the screen. - Claim narrowly, then own the gesture —
touch-action: pan-yleaves the list scrolling and any movement past 10px abandons the hold, so a peekable row costs nothing to scroll past. Only once the peek is actually open does a native{ passive: false }touchmove listener start callingpreventDefault, which is the one moment the component is allowed to take the gesture away from the browser. - Slide to choose, or let go and tap — the same press that opened the menu keeps picking: the finger is hit-tested against
elementFromPoint(pointer capture means item-level pointer events never arrive), and releasing over a row fires it. Releasing anywhere else leaves the menu up as an ordinary tap-and-arrow-key menu, so the gesture has no dead end and the keyboard path is the same code. - Anchored, flipped, thumb-side — placement is solved once against a frame that is the visual viewport minus the safe area minus a gutter: the menu goes below the target, flips to keep the clone's bottom edge on the target when it would overflow, and hangs off whichever edge the target is already nearest, which on a phone is the edge the thumb can reach.
visualViewportis what keeps it above the software keyboard. - One highlight index, two inputs — the finger and the arrow keys write the same index, published with
aria-activedescendantwhile DOM focus stays on the menu container. That is why a highlight can never disagree with what a screen reader is announcing, and why the highlighted row inverts tobg-foreground text-background: it has to stay legible with a finger sitting on half of it. - Refusals are first class — an empty
actionslist means the peek never opens andaria-haspopupis dropped, rather than lifting a target to show an empty menu; disabled actions arearia-disabled(never the native attribute, which would blur a focused node) and are skipped by both the finger and the arrows.
Action Sheet
An OS-style sheet of grouped choices raised from the bottom edge, with a destructive tone, a detached Cancel, drag-to-dismiss and safe-area padding.
Share Sheet
The system share surface: a subject header you can pull down to dismiss, a swipeable recents rail, a 4-up app grid and an activity list, on one scrollable sheet with safe-area padding.