Double Tap Heart
A media surface that likes on double tap, bursting a mark where the thumb landed while the first tap is left to the control underneath.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/double-tap-heart.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "DoubleTapHeart" component (lucide-react
for the glyph, a cn() class merger, nothing else — the recogniser, the marks and
the safe area are the whole point and must be self-contained). It is the mobile
double-tap-to-like gesture layered over a media surface, NOT a like button.
Contract
- forwardRef<HTMLDivElement>, extends HTMLAttributes minus children, spreads the
rest onto the root. children is the surface (an image, a video, a text block).
- liked?: boolean (controlled — the component then never flips itself, it only
reports intent), defaultLiked?: boolean (uncontrolled seed, default false),
onLikedChange?: (liked: boolean, source: "gesture" | "button") => void.
Both modes are supported; `source` is what lets a consumer log or animate the
two paths differently.
- onDoubleTap?: (point: { x: number; y: number }) => void fires on EVERY
recognised double tap, including one that changes nothing (a re-affirm). The
point is in px relative to the component's own box.
- variant?: "burst" | "center" | "float" (default "burst"), interval?: number
(ms between the two taps, default 300, clamped 120..800), slop?: number (px
between the two taps and travel allowed inside one tap, default 24, clamped
4..120), doubleTapUnlikes?: boolean (default false), showButton?: boolean
(default true), count?: ReactNode (rendered verbatim — the consumer owns it,
so a server refresh can correct it), hint?: string, glyph?: ReactNode
(replaces the heart; an svg child is stretched to the mark box),
disabled?: boolean, disabledReason?: string, label?: string (when set the root
becomes role="group" with that name), labels?: Partial<{ like, liked,
likedAnnouncement, unlikedAnnouncement }>.
- Consumer numbers are clamped, never trusted: a NaN interval must not turn the
component into something that recognises nothing.
Behavior
- Pointer Events only, never separate mouse/touch handlers, and never
preventDefault on a move — nothing here needs a non-passive listener. Six
handlers are owned: pointerdown/move/up/cancel and lostpointercapture each
forward to the consumer's own handler first, and pointerdown alone bails when
that handler has already called preventDefault. clickCapture is the exception
— when a suppression is armed it eats the click outright, so the consumer's
onClickCapture runs only on the clicks that were let through.
- THE FIRST TAP IS NEVER OURS. On the first pointerdown nothing is captured,
delayed or cancelled, so a play button, a link or a pager under the finger
behaves exactly as it would without the wrapper. Only the landing point and
the time are remembered.
- A second pointerdown within `interval` of the first tap and within `slop` of
its point is a candidate: setPointerCapture is taken on the element the
gesture is recognised on (and released on that same node), because this is the
tap the component intends to consume.
- A tap is not a press and not a drag: it must lift within 500ms and travel less
than `slop`. The travel flag latches, so a finger that wandered cannot come
back and be a tap. A hold or a drag also CLOSES the chain, which is what keeps
hold-then-tap from reading as a double tap and lets a long-press menu share the
same surface.
- On recognition: a one-shot ref is set — read AND written synchronously inside
the handler — and consumed in a capture-phase click listener on the root,
which preventDefaults and stopPropagations the click that follows THIS tap.
That is what stops the control underneath answering twice. The ref is also
cleared on the next pointerdown so a stale suppression can never eat an
unrelated click. The chain is reset rather than extended: a third tap opens a
new pair instead of firing a second like off one extra tap.
- A second finger (a pinch, a two-finger scroll) drops the whole candidate
rather than guessing; pointercancel does the same; lostpointercapture clears
the press for the case where capture dies without a pointerup.
- Liking: by default a double tap LIKES and re-affirms — an accidental second
double tap on a photo you just liked must not silently undo it.
doubleTapUnlikes makes the gesture a toggle. The button always toggles both
ways. In uncontrolled mode a ref mirroring the state is written synchronously
so a second gesture in the same frame reads the new value; in controlled mode
that ref is only ever written from the prop, so a consumer that refuses the
change never leaves the component believing it won.
- Marks: each double tap spawns one, positioned in PERCENTAGES of the surface
box so a resize mid-flight cannot strand it, with a deterministic per-mark
jitter derived from a counter (no Math.random, so a replay produces the same
flurry). At most 12 are in flight; the oldest is dropped rather than letting a
mashing thumb grow the DOM. Each mark owns its own life: a double
requestAnimationFrame to paint the parked frame before the transition starts,
a timer to send it out, a timer to unmount it — all cancelled on unmount and
on any dependency change.
- prefers-reduced-motion is subscribed through matchMedia (not read once, and
never read during render — useSyncExternalStore with a false server snapshot).
With motion off the mark still appears at full size and still leaves; only the
travel and the scaling go. Recognition, liking and announcing are untouched.
- Accessibility: a real button carries aria-pressed and the accessible name
("Like" / "Liked"); every state change lands in a polite, self-clearing live
region, which is the only way a like made by the gesture is announced at all.
`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; pressing a refused button announces disabledReason.
showButton={false} keeps the button focusable and announced (opacity 0 and
pointer-events none, restored on focus-visible) so the gesture never becomes
the only way in. `hint` is aria-hidden on purpose: a screen reader's own
double tap already means "activate", so repeating the instruction would lie.
Rendering & styling
- Semantic tokens only, monochrome first: bg-card / text-card-foreground surface,
border hairlines, bg-background/70 + backdrop-blur chips, text-muted-foreground
for the hint, focus-visible:ring-2 ring-ring. No hex, no rgb(), no oklch().
The pressed button INVERTS (bg-foreground / text-background) instead of taking
a colour; the mark is text-foreground with a drop shadow so it survives busy
media.
- The root sets touch-action: manipulation — load-bearing, because otherwise the
browser holds every tap for its own double-tap-to-zoom and the gesture arrives
late or zooms the page — plus select-none and a transparent tap highlight, so
the second tap neither selects text nor flashes a grey box.
- Three presentations, structurally different rather than recoloured: "burst" =
an 80px mark at the tap point that pops with an overshoot easing and drifts up
as it fades; "center" = a 112px stamp locked to the middle, a statement about
the whole surface; "float" = 40px marks that rise ~210px on a long ease and
drift sideways by their own jitter, so mashing reads as a flurry.
- Touch: the button is a 44px circle; the count chip and the hint sit on the
opposite corners so neither is under the thumb.
- Safe area: the button and the hint are offset with
max(var(--safe-area-inset-<edge>, env(safe-area-inset-<edge>, 0px)), 0.75rem)
so a full-bleed photo keeps its like button clear of the home indicator.
Reading the custom property first is what lets a device frame simulate insets
on hardware that reports 0.
Customization levers
- Presentation: `variant` is the main dial (burst for a feed photo, center for a
full-screen viewer, float for a live stream). The per-variant timing and glyph
size live in one map at the top of the file — three numbers and a size class
each; change them there rather than per render.
- Feel: `interval` and `slop` are the whole recogniser. Raise the interval for a
forgiving double tap on a large phone, raise the slop for a moving surface,
lower both when a single-tap control underneath must answer sooner. The 500ms
press ceiling and the 12-mark cap are the two constants beside them.
- Semantics: `doubleTapUnlikes` decides whether the gesture is a toggle or an
affirmation. Swap the mark entirely with `glyph` (a star, a brand svg, an
emoji) — the button keeps the heart unless you change it there too.
- Chrome: drop `count` to render nothing for it, drop `hint` for a bare surface,
set showButton={false} when you draw your own control (the accessible one
stays behind it), pass `label` to make the whole thing a named group.
- Safe area floors: the 0.75rem second argument of the inset helper is the
minimum gutter on hardware that reports 0 — raise it for a roomier corner.Concepts
- First-tap passthrough — the recogniser refuses to own the first tap. No capture, no delay, no swallowed click, so a play button or a link under the finger behaves exactly as it would without the wrapper. The alternative — waiting out the double-tap window before letting the first tap through — buys perfect arbitration at the price of 300ms of lag on every single tap, which is the wrong trade on a surface where single taps are the common case.
- Second-click suppression — the price of that passthrough is that the second tap would otherwise toggle the control right back. A ref written and read inside the same handler arms a capture-phase click listener on the root, which stops that one click before it reaches anything below. State could not do this: it settles a frame too late, and the click has already landed.
- Tap-versus-press arbitration — a tap must lift within 500ms and travel under the slop, and failing either closes the chain rather than just skipping one tap. That is what lets a long press, a scroll and a double tap share one surface: each gesture disqualifies itself early instead of racing the others to a threshold.
- Tap-point anchoring — the mark is stored as a percentage of the surface box, not as pixels, so a rotation or a resize while it is in flight moves it with the picture instead of stranding it in a corner. Its tilt and drift come from a counter, never Math.random, so the same taps replay the same flurry.
- Affirm, do not undo — by default the gesture only ever likes. A double tap is cheap and easily triggered by a thumb steadying itself, so making the second one destructive turns a stray touch into silent data loss; taking it back is left to the button, or to the consumer opting into
doubleTapUnlikes. - Twin path — every gesture here has a real button doing the same job, carrying
aria-pressedand announcing through a polite live region. Hiding it visually does not remove it: it stays focusable and comes back on keyboard focus, because a like that can only be made by a gesture cannot be made at all by someone using a switch or a screen reader.
Long Press
Press-and-hold on any row or tile — a ring, bar or swell charges under the finger, drift hands the gesture back to the scroller, and the completion fires exactly once, with a tap, a held key and a 44px button all reaching the same action.
Pinch Zoom
A photo you pinch, drag and double-tap inside its own frame: fit is 100%, every edge is a rubber band, and the sideways swipe past an edge is reported to the host instead of performed.