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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/reachability-pull.jsonPrompt
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
preventDefaultanywhere 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-phaseall 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 hold —
latchis a mode the user is in until something ends it;holdis 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:
restoreOnActivatemeans 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.
Thumb Toolbar
A bottom action bar sized to the thumb-reach arc: a hand-side overflow key that opens its menu upward, press-and-slide selection, safe-area padding and a full keyboard path.
List Index Bar
An edge-hugging A–Z rail that jumps a sectioned list from one continuous thumb drag, with a magnified preview bubble, dot compression and a full keyboard path.