Mobile

Swipe To Reply

A chat row whose sideways swipe arms a reply — the row tracks the thumb, a mark fills the gutter behind it, and past the threshold releasing commits and springs the row straight back.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { CornerUpLeft, CornerUpRight, X } from "lucide-react"
import { cn } from "@/lib/utils"

/** Travel (px) before the gesture picks an axis. Under it the press still belongs to the list. */
const LOCK_PX = 8
const DEFAULT_THRESHOLD = 64
const MIN_THRESHOLD = 32
const MAX_THRESHOLD = 240
/** Default ceiling on the travel: half again past the commit point, then the row stops moving. */
const MAX_TRAVEL_FACTOR = 1.6
/** The band only approaches the ceiling, so a ceiling at the threshold is a gesture that can never arm. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/swipe-to-reply.json

Prompt

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

Build a React + TypeScript + Tailwind "SwipeToReply" component (lucide-react for
the mark, a cn() class merger, nothing else — the physics, the arbitration and the
safe area are the whole point and must be self-contained). It wraps ONE chat row
and turns a sideways drag into a single "reply to this" commit. It is not a
swipe-to-reveal row: there is no pane to tap and the row never rests open.

Contract
- forwardRef<HTMLDivElement>, extends HTMLAttributes, spreads the rest onto the
  root. children is the row (a bubble, an avatar plus text, anything) and stays
  interactive.
- label: string — what this row is ("Ada at 9:41"). It names the button and every
  announcement, so it is required, not decorative.
- onReply: (source: "gesture" | "button" | "keyboard") => void fires on EVERY
  commit, including one that re-affirms a row that is already the pending target.
  Quoting the message is the consumer's job; this component only reports intent.
- replying?: boolean (controlled — the component then never flips itself),
  defaultReplying?: boolean (uncontrolled seed, default false),
  onReplyingChange?: (replying: boolean, source) => void, which fires only when
  the boolean really changes. Both modes are supported: uncontrolled for a single
  row, controlled for a thread that must keep exactly one row armed.
- variant?: "gutter" | "badge" | "rail" (default "gutter"),
  direction?: "right" | "left" | "both" (default "right", PHYSICAL — a thumb arc
  does not flip with the text), threshold?: number (px, default 64, clamped
  32..240), maxTravel?: number (px ceiling, default 1.6 * threshold, clamped to at
  least 1.15 * threshold), showButton?: boolean (default true), icon?: ReactNode,
  haptics?: boolean (default true), disabled?: boolean, disabledReason?: string,
  labels?: Partial<{ armed, reply, cancel, replying, cancelled, refused }>,
  contentClassName?: string for the moving surface.
- Consumer numbers are clamped, never trusted: a NaN threshold would arm on
  contact, and a maxTravel at the threshold is a gesture that can never arm.

Behavior
- Pointer Events only — never separate mouse/touch handlers — and never
  preventDefault, because the root declares `touch-action: pan-y`: the browser
  keeps the vertical scroll, this component owns the horizontal axis, and nothing
  has to fight a passive listener to get it. Every handler the component owns
  calls the consumer's own handler first and bails when it has already called
  preventDefault.
- Axis lock: nothing moves for the first 8px. On the first decisive movement the
  gesture is claimed only if it is more horizontal than vertical AND points a way
  this row accepts; otherwise the record is dropped FOR GOOD, so a diagonal flick
  still scrolls the thread and can never be stolen back halfway through. On the
  claim, setPointerCapture is taken on the element the press started on (released
  on that same node), the start x is re-baselined so the row begins at 0 instead
  of jumping the lock distance, and any text selection a held mouse painted on the
  way is cleared.
- Physics: 1:1 with the finger up to the threshold, then an exponential band that
  only ever APPROACHES maxTravel, so the row cannot be dragged into the next
  screen. Distance decides, never velocity: a fast flick that stopped short does
  not send a reply, because committing something the user did not aim at is worse
  than making them swipe again.
- Arming is a crossing, not a per-frame state: the mark inverts, one short
  navigator.vibrate fires where the platform has one (a no-op on all of iOS), and
  the live region says "Release to reply" ONCE — not 120 times.
- Release: past the threshold it commits and the row springs home on the same
  260ms curve as the gutter, so the two never drift apart. Under it, nothing
  happened. A pointercancel or a lost capture is not a decision either: spring
  back, say nothing. The gesture record is read AND cleared in the same tick, so a
  pointerup followed by a pointercancel can never commit twice. A drag that moved
  swallows exactly one following click, so a bubble with its own onClick is not
  also opened by the swipe that just ended over it.
- The pending target: a commit sets `replying`, which tints the row and draws a
  leading rail — the same state the composer's quote chip is showing. A second
  swipe re-affirms rather than toggling: an accidental repeat must not silently
  drop the quote the user just made.
- Twin path. The reply button is a real 44px button that commits with the same
  code; ArrowRight (or ArrowLeft, mirrored per `direction`) commits from anywhere
  inside the row; Escape drops the pending reply. The button and the key play the
  same travel the thumb would have drawn, so the three paths do not read as three
  different features — skipped under reduced motion, where it would be a flicker.
- ONE node does both jobs: the same button is "Reply to X" and, once armed,
  "Cancel reply to X". A separate cancel button would have to unmount the reply
  button at the moment of commit, dropping focus onto <body> for anyone who
  committed from the keyboard.
- showButton={false} keeps that button focusable and announced (opacity 0,
  pointer-events none, both restored on focus-visible) so the gesture never
  becomes the only way in. `disabled` is aria-disabled plus a handler guard, never
  the native attribute — the browser blurs a node the instant it becomes disabled
  and the user may be standing on it; a refused row announces disabledReason.
- prefers-reduced-motion is subscribed through matchMedia (never read during
  render — useSyncExternalStore with a false server snapshot). With motion off the
  row still tracks the finger 1:1 (direct manipulation is not decoration), the
  mark still appears and still inverts; only the eased way home and the
  button/key travel are dropped.
- Cleanup: the announcement timer and the pulse timer are cleared on unmount, the
  matchMedia listener unsubscribes, pointer capture is released on the node that
  took it, and no listener outlives the row.

Rendering & styling
- Semantic tokens only, monochrome first: bg-background / bg-accent for the
  moving surface, bg-muted + text-muted-foreground for the resting mark, border
  hairlines, focus-visible:outline-ring. No hex, no rgb(), no oklch(). The armed
  mark and the pending rail INVERT (bg-foreground / text-background) rather than
  taking a colour — that flip is the whole warning that release will commit.
- The moving surface must be opaque and sit above the gutter: the gutter is a
  sibling underneath it, and a transparent row shows the mark through itself at
  rest.
- Three presentations, structurally different rather than recoloured. "gutter":
  a round mark anchored to the track's outer edge, uncovered and scaled up as the
  row slides away from it. "badge": the mark rides WITH the row at a fixed gap,
  wearing a progress ring that closes exactly at the threshold. "rail": a slab of
  the inverted surface fills the gutter — implemented as a fixed-width box
  translated into view, never a growing width, because width cannot be
  transformed and a width transition drifts out of step with the row.
- Touch: the button's hit area is 44px, nothing depends on hover, and every
  gesture has a visible control or a key that does the same.
- Safe area: the row's content is padded with
  max(var(--safe-area-inset-<edge>, env(safe-area-inset-<edge>, 0px)), 0px) on
  both inline edges, so a full-bleed thread keeps its bubbles clear of the sensor
  housing in landscape. The gutter behind them deliberately is not padded — it is
  allowed to run to the physical edge. Reading the custom property first is what
  lets a device frame simulate an inset on hardware that reports 0.

Customization levers
- Presentation: `variant` is the main dial (gutter for a classic thread, badge
  when the bubble should carry the mark, rail for a dense list read at arm's
  length). Swap the glyph entirely with `icon` — mirroring it for a leftward
  swipe is then yours.
- Feel: `threshold` and `maxTravel` are the whole gesture. Raise the threshold on
  a large phone or where an accidental reply is expensive; lower it for a
  reply-heavy thread. maxTravel is the theatre past the commit point, not a second
  threshold. The 8px axis lock, the 260ms way home and the 150ms button travel are
  the three constants beside them.
- Direction: set it per row rather than globally — incoming bubbles usually take
  "right", your own take "left", and "both" suits a centred or mixed thread.
- Chrome: `showButton={false}` when your row already carries its own control (the
  accessible one stays behind it), `contentClassName` to move the row onto a card
  surface, `labels` for i18n, `haptics={false}` where a tick would be noise.
- State: leave `replying` off for a single row; drive it from the composer for a
  thread that must keep exactly one row armed.

Concepts

  • Arming before committing — the gesture states its intention while the finger is still down: past the threshold the mark inverts, a haptic tick fires and the live region says it once. Everything that happens on release was announced before release, so nobody discovers the commit by accident — and a swipe that changed its mind just goes back.
  • Spring back, never rest open — the row has no open state to tap, which is the whole difference from a swipe-to-reveal row. One decision is taken at release and the row returns to zero, so a thread of 200 rows can never end up with three of them hanging half-open behind the one the user is reading.
  • Distance decides, not velocity — a fast flick that stopped short does not send a reply. Velocity snapping is right when the outcome is a resting position you can see and undo; it is wrong when the outcome is a message quoted in a composer, because the cost of committing something the user did not aim at is higher than the cost of one more swipe.
  • Axis lock, then hand it back — nothing moves for the first 8px, and the first decisive movement either claims the gesture or drops it permanently. A gesture that failed the test is never re-evaluated, which is what stops a diagonal flick from stealing the list's scroll halfway down the screen.
  • Rubber band as theatre — past the threshold the row keeps moving but earns less and less, approaching a hard ceiling it never reaches. The resistance carries no information the threshold has not already given; it exists so the gesture feels like something physical being held rather than a div being dragged off screen.
  • Twin path, one node — the same commit sits on a real button and on an arrow key, and that button turns into the cancel for the reply it just armed rather than being replaced by a second one. Swapping a name and a handler on one element keeps focus exactly where the user left it; unmounting a focused control drops focus onto the document body, which is how keyboard users lose their place in a long thread.

On This Page