Mobile

Reachability Pull

One-handed mode as a wrapper: a pull on the home-indicator strip brings the whole screen down into the thumb arc — sliding, shrinking or leaning into a corner — with a button, arrow keys, Escape, a tap and a timeout that all send it back.

Preview in your theme

Loading preview…

"use client"

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

/** How the screen gets out of its own way. Mirrored onto the root as `data-variant`. */
export type ReachabilityPullVariant = "shade" | "scale" | "corner"
/** `latch` keeps the screen down after the finger lifts; `hold` springs it back on release. */
export type ReachabilityPullMode = "latch" | "hold"
/** Which thumb the screen leans towards. Mirrored onto the root as `data-hand`. */
export type ReachabilityPullHand = "left" | "right"
/** Where the interaction is. Mirrored onto the root as `data-phase`. */
export type ReachabilityPullPhase = "idle" | "pull" | "armed" | "engaged"

Installation

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

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ReachabilityPull" component — the phone
feature where the whole screen comes down out of the thumb-reach arc so the top
row can be tapped one-handed (iOS Reachability, Android one-handed mode). React +
lucide-react only: no gesture library, no animation library.

Contract
- "use client". forwardRef<HTMLDivElement, ReachabilityPullProps> extending
  React.HTMLAttributes<HTMLDivElement>; the rest props spread onto the root.
- Props:
  - variant: "shade" | "scale" | "corner" = "shade". How the screen gets out of
    its own way. shade = the whole screen slides down, type size untouched.
    scale = it shrinks towards the bottom edge, so the bottom rows stay on screen
    too. corner = it shrinks towards the thumb corner picked by `hand`. All three
    make the SAME promise — the top edge ends up exactly `distance` lower — so
    they are interchangeable without re-tuning anything else.
  - mode: "latch" | "hold" = "latch". latch keeps the screen down after the
    finger lifts; hold springs it back on release, making it a peek that never
    becomes a state — in hold mode the gesture never reports engaged, only the
    button and the keys do. They latch in both modes, because a keyboard cannot
    hold a button down.
  - hand: "left" | "right" = "right". Places the toggle button and, for the
    corner variant, the anchor. It is a handedness setting, never a locale.
  - reach = 0.38 — how far the top edge travels, as a share of the measured
    screen height. Clamped 0.12..0.7. The shrinking variants read better lower
    (0.2-0.25), where the same number is also how much smaller everything gets.
  - threshold = 56 — pull in px at which the gesture arms. Clamped 16..240 AND
    never past 60% of the travel: an arm point beyond the stop is a gesture that
    can never fire.
  - autoRestoreMs = 5000 — put the screen back on its own, the way the platform
    feature does. 0 turns it off. Clamped 0..120000, and paused while a finger is
    on it so it can never fire mid-gesture.
  - restoreOnActivate = true — restore as soon as anything inside the screen is
    activated: the user came down for that control, tapped it, and is done.
  - engaged / defaultEngaged = false / onEngagedChange — controlled and
    uncontrolled both supported; onEngagedChange fires for every path.
  - showToggle = true, disabled = false.
  - labels?: Partial<{ pull; release; engaged; restore; restored; toggle; shade }>
    — the only prose in the component, for i18n.
  - children: the app screen being wrapped.
- Export the variant / mode / hand unions plus the phase union
  "idle" | "pull" | "armed" | "engaged", and mirror phase, variant, hand and
  engaged onto the root as data-attributes for skinning.
- Clamp every numeric prop through one helper that rejects NaN: a NaN reach would
  paint a NaN transform and the screen would disappear.

Behavior — the gesture lives on the device edge, not on the content
- Structure: a clipping root (position: relative, overflow: hidden, its own
  height) with three layers — the shade (absolute inset-0, z-0) behind
  everything, the screen (absolute inset-0, z-10, transformed, holds children),
  and the home-indicator strip (absolute bottom, z-20) that never moves.
- The drag starts ONLY on the strip. That single decision removes the whole
  arbitration problem: the content inside the screen can scroll, swipe and drag
  as it likes, this component never has to guess whose gesture it is, and it
  therefore attaches no non-passive listener and calls preventDefault nowhere.
  The strip carries `touch-action: none` so the browser does not compete for it.
- Pointer Events only, and setPointerCapture is taken on pointerdown on the strip
  itself — the element the gesture started on. There is nothing clickable left in
  the strip, so there is no click target to rewrite, and capture is what
  guarantees the release arrives even when the finger has left the element. The
  toggle button inside the strip carries a data attribute that makes pointerdown
  bail out before capturing, and the origin is tested with
  `event.target instanceof Element` — a press landing on the button's <svg> is an
  SVGElement, and reading that as "not the button" would swallow its click.
- Distance tracks the finger 1:1 for the whole travel (this is direct
  manipulation: the screen belongs exactly where the thumb put it), then damps at
  0.28 and hard-stops 8% past `reach * height`.
- The same strip works both ways: while the screen is down, dragging the strip up
  brings it back. "Armed" always means one thing — let go now and the state
  flips — whichever direction the finger is travelling, so one word, one
  highlight and one hint cover both directions. Hold mode releases into a restore
  however far the pull went, so there a downward pull never arms and an upward one
  is armed from the first pixel — otherwise the hint would promise a latch that
  the release never delivers.
- Release: armed, or a flick over 0.45px/ms in the right direction, commits the
  flip; anything else settles back where it started. In hold mode a release
  always restores. A pointercancel settles back without committing.
- Restore paths, all running the same transition: the toggle button, Arrow Down /
  Arrow Up on it, Escape anywhere inside the root (handled only while the screen
  is down, so nothing inside it ever loses the key), a tap on the vacated strip
  (which is one big button), an activation inside the screen when
  restoreOnActivate is on, and the autoRestoreMs timeout.
- disabled blocks engaging only. A screen that is already down can always be
  restored — stranding a user with the UI parked halfway would be the worst bug
  this component could ship.
- Controlled refusal is a first-class path: onEngagedChange fires, the parent
  says no, and the resting effect springs the screen straight back to whatever
  the prop still says.
- Cleanup: the auto-restore timer is cleared on unmount, on restore and on every
  dependency change; the ResizeObserver is disconnected; the matchMedia
  subscription is owned by useSyncExternalStore; the in-flight drag record is
  dropped on unmount; pointer capture is released on the node that took it.
- Focus: the vacated strip is a real <button> that is in the tab order only while
  the screen is down. When it goes inert, focus standing on it is handed to a
  deliberate successor (the toggle, else the root) in a layout effect — never
  left to fall onto <body>.

Rendering & styling
- Semantic tokens only: root border + bg-muted, shade bg-muted +
  text-muted-foreground, screen bg-background + ring-1 ring-border, toggle border
  + bg-card/85 + hover:bg-accent / hover:text-accent-foreground, pill
  bg-foreground/25 rising to /60 while it is in use, focus-visible:ring-2
  ring-ring everywhere. The engaged toggle INVERTS (bg-foreground text-background)
  rather than taking a colour — this is a monochrome system and colour is
  reserved for real semantics.
- The moving pixels are written straight to the DOM (transform + transition on
  the screen, opacity on the hint) from one paint() function. Only "is a finger
  down" and "would a release flip it" live in React state, and both are mirrored
  in refs, so a 60fps drag re-renders the consumer's whole screen exactly twice.
  Keep ONE writer: an effect paints every resting position — after a release,
  after a resize, after a variant change, after a refusal.
- Height is measured with a ResizeObserver, never assumed: the travel is a share
  of the current height, so a rotation or a software keyboard changes it.
- Safe area: the strip pads bottom / left / right and the hint pads top with
  `max(var(--safe-area-inset-<edge>, env(safe-area-inset-<edge>, 0px)), floor)`.
  Reading the custom property first lets a device frame or a preview simulate a
  notch; the max() floor keeps the layout sane where the OS reports 0.
- Accessibility:
  - The toggle is a real <button type="button"> with aria-pressed and an
    aria-label, and it never takes the native disabled attribute — the browser
    blurs a node the instant it is disabled, dropping focus onto <body>.
  - Keyboard map: Tab reaches the toggle and, while the screen is down, the
    vacated strip; Enter / Space toggle; Arrow Down engages; Arrow Up restores;
    Escape restores from anywhere inside the root. No other key is intercepted,
    so the wrapped screen keeps every key it had.
  - The shade carries aria-hidden and tabIndex -1 while the screen is up, so an
    inert restore target is never announced or focusable.
  - A polite sr-only role="status" announces the two outcomes only — never the
    drag — and it is driven by the RESOLVED state (compared during render against
    a previous-value state), so a refused request is never announced as accepted.
  - prefers-reduced-motion (subscribed with useSyncExternalStore over matchMedia,
    never read during render): every transition is dropped. The screen still
    moves, because that is the feature and not decoration — a pull that follows
    the finger, and a state that changes instantly instead of easing.

Customization levers
- reach is the feel: 0.35-0.4 for a shade on a 6.7" phone, 0.2-0.25 for the
  shrinking variants, 0.5 when a whole app bar has to come down. threshold is how
  deliberate the gesture is (56 default; 90+ where an accidental shift would be
  annoying).
- autoRestoreMs is the "do not leave it there" knob: 5000 like the platform, 0
  for an accessibility setting the user turns on and keeps on.
- restoreOnActivate={false} together with autoRestoreMs={0} turns a one-shot
  reach into a persistent mode.
- labels is the only prose in the component; every hint is a two-line clamp, so
  long translations wrap instead of overflowing.
- Skin by data-attribute: data-phase="armed" for the committed look, data-engaged
  for the shade, data-variant / data-hand for per-variant chrome, and
  data-slot="reachability-shade | reachability-screen | reachability-strip" for
  the three layers.
- showToggle={false} only if you render your own control calling the same setter
  — keep ONE of the two, never zero, or the feature is touch-only.
- Height belongs to the consumer: className="h-dvh" / "h-[32rem]" merges last via
  cn(). Give the root a height or the travel is zero.
- The strip owns the bottom band the way a home indicator does. Keep interactive
  content above it: pad the wrapped screen with the same bottom inset.

Concepts

  • The thumb-reach arc — a hand holding a 6.7" phone sweeps a circle about 12cm across from the bottom corner, and the top 30–40% of the screen falls outside it. Every other fix for that is a layout decision (move the navigation to the bottom, shrink the header). This is the temporary, reversible one: leave the layout alone and move the whole screen for a few seconds. It is meaningless on a desktop, where a cursor reaches every pixel at the same cost.
  • The screen moves, the chrome stays — the gesture lives on the home-indicator strip pinned to the physical bottom edge, not on the content. That is what keeps the way back reachable after the way in, and it also removes the arbitration problem entirely: nothing here competes with a scroller, so there is no axis lock, no non-passive listener and no preventDefault anywhere in the component.
  • Armed means it will flip — one word covers both directions. Pulling down from rest, armed means letting go latches; pulling up while it is down, armed means letting go restores. The hint, the pill highlight and data-phase all change before the finger lifts, so the outcome is never a surprise — and a flick past 0.45px/ms decides on its own however short it was.
  • Latch versus holdlatch is a mode the user is in until something ends it; hold is a peek that exists only while the finger is down. They are genuinely different interactions rather than a setting: a peek needs no way out, a mode needs five. The button latches in both, because a keyboard cannot hold.
  • Every way out at once — a tap on the vacated strip, Escape, Arrow Up, the toggle, an upward drag, a timeout, and an activation inside the screen all run the same restore. The last one matters most: restoreOnActivate means the tap the user came down for both fires and puts the screen back, so the common case costs exactly one touch.
  • A refusal is a first-class path — controlled, this component asks and the parent answers. When the answer is no, the screen springs back from wherever the finger left it, and the polite announcement is driven by the resolved state rather than by the request, so a screen reader is never told the mode is on when it is not.

On This Page