Double Tap to Like

I greatly admire the “double tap to like” interaction.

It removes precision from an action that would otherwise demand it. Instead of locating a small heart icon, users can tap almost anywhere on the content in front of them. The content itself becomes the button.

This matters because liking is rarely the user’s primary task—they are there to watch, scroll, and discover. By making the gesture effortless, the interaction lets them express appreciation without interrupting the flow of consumption.

I wanted to recreate it as an exercise; to understand what makes it work and how it might be extended. What looks like a simple interaction is really a series of small decisions coming together to make it feel delightful.

Observations

I always like to start by recording the interaction I am studying, then playing the recording back and forth several times at a reduced speed to understand the subtle nuances of the interaction.

A few things stand out immediately. First, the heart appears at the position of the double tap. It then moves from that point to a fixed destination—the Like button beside the other actions.

Second, the heart doesn't follow a straight path to the destination. It rises slightly from the spawn position, holds for a moment, then cuts diagonally into the like button in the corner.

Another key thing to notice is the nature of the interaction. It is swift by nature, giving enough visual feedback to show that a like was registered without being intrusive, since the user would most likely be engrossed in the content. You can also spam the interaction, and every double tap fires a new instance of the same loop.

There is also subtle haptic feedback when you like a reel. However, spamming the interaction does not produce repeated haptic feedback. It is only triggered when you like or unlike a reel.

First Animations

To start with, we need to detect the exact location of the double tap on the screen, as this is where the heart will spawn.

We start by identifying the valid region for the double tap. You can double tap anywhere on the screen except within the small regions reserved for action items—like, comment, share, and more—and the account details and reel description at the bottom.

Lets define a reusable double tap gesture using the React Native Gesture Handler

------–––––––––––––––––––––-------------------------------------------------

I double-tap to like things more than I do almost anything else on my phone, and until recently I'd never actually looked at what happens when I do. So I slowed a recording down and stepped through it.

The heart doesn't travel in a straight line. It leaves your finger going straight up, holds for a moment, then cuts diagonally into the like button in the corner. It swells to about three times its final size on the way up and shrinks the whole way down.

That hold is the part I couldn't stop noticing. It's maybe five frames. Take it out and the heart still arrives at the same time, but it reads like something being moved rather than something being thrown.

Here's the version we'll end up with. Double-tap the video.

443K
2,679
8,314
instagramFollow
shredding in Peruvian sand
Add comment...
double-tap the video
The finished interaction.

Rebuilding it turned out to involve less animation than I expected. Three of the four problems have nothing to do with motion at all.

The tap has to survive the screen

Let's start with the part that isn't animation.

The double tap isn't attached to a button. It's the whole video, which is the whole screen — and that screen already has a like button, a comment button, a share button, a caption, and a text input along the bottom. Every one of them is tappable. None of them should like the post.

The obvious fix is to check the tap coordinates against a list of rectangles and bail if it falls inside one. I'd avoid it. Those rectangles move: the rail is anchored to the bottom inset, the caption grows with its text, the input bar changes height when the keyboard appears. You end up maintaining a second, parallel copy of your layout that goes stale without telling you.

Gesture Handler has a better version of the same idea. An empty Gesture.Tap() does nothing except claim the touch, and a gesture that claims a touch stops the one above it from seeing it:

No coordinates, no rectangles. The exceptions live in the same place as the layout, so they can't drift out of sync with it.

It helps to stop looking at the screen as an image. What the compositor hands the user is one flat picture, but what the gesture system walks is a stack — and the rail blocks the like because of where it sits in that stack, not because it knows its own coordinates. Set explode to flat in the panel and the two planes fold back into the picture you started with.

443K
2,679
8,314
instagramFollow
shredding in Peruvian sand
Add comment...
The video, and everything sitting on top of it that refuses your tap.

maxDelay(250) deserves a second of attention. It's the window in which a second tap still counts as part of the first. Too generous and two deliberate, separate taps get read as a like — the specific bug that makes an app feel like it's arguing with you. Too tight and you lose anyone who doesn't tap briskly.

The demo below tints every region that refuses the gesture. Try to like the post by double-tapping the comment box; the counter will tell you it saw the tap and threw it away. It's worth turning the tint on just to see how much of the screen is already spoken for — roughly a third, and it's the third your thumb naturally rests on.

443K
2,679
8,314
instagramFollow
shredding in Peruvian sand
Add comment...
0 registered0 rejected
The regions that refuse the gesture.

Nothing animates yet, and that's deliberate. An animation on top of a gesture that fires at the wrong moment is a faster wrong answer.

Two points, and one of them is hidden

The flight needs a start and an end.

The start is free — the gesture hands you event.x and event.y, already relative to the view you attached it to.

The end is the problem. The heart flies to the like button, and the like button has no fixed position. It sits in a rail anchored to the bottom of the screen, above a tab bar, above the home indicator. Its position depends on the device. Hardcode it and it's correct on exactly one phone.

So measure it:

That last part is the easy thing to get wrong. onLayout reports a position relative to the parent, not the screen. Forget to add the rail's offset back and the heart flies, confidently and smoothly, to a point somewhere off the bottom edge.

Which is really the thing that made this tractable: I couldn't see either number. I was tuning a trajectory between two points I was taking on faith, so when the heart missed the icon I had no way to tell whether the path was wrong or the destination was.

So before animating anything, I drew them both.

Double-tap anywhere below. The crosshair is the contact point with its coordinates; the small ring on the like button is the measured destination; the dashed line between them is the flight before any flight exists. Resize the window and watch the ring move — that's the measurement re-running, and it's the bug you'd otherwise ship to every screen size but your own.

443K
2,679
8,314
instagramFollow
shredding in Peruvian sand
Add comment...
Both ends of the flight, before anything moves.

This is the last moment where being wrong about the geometry costs nothing.

One aside, since it comes up the moment you try to print a live coordinate: pushing a value that changes every frame through React state is sixty renders a second for one line of text. Reanimated's escape hatch is useAnimatedProps on a TextInput — the one component whose content is a prop rather than a child, so it can be written from the UI thread without involving React at all.

One number, not five

Now the motion.

My first attempt animated five things: x, y, scale, rotation, opacity. Five timings, five durations. It worked, and it looked subtly wrong in a way I couldn't place for an embarrassingly long time.

In hindsight it's obvious. Five animations started together don't stay together, and every time I adjusted one duration I was silently desyncing the other four.

The fix is to stop animating properties and animate a single number. progress runs 0 to 1. Everything else is a function of it:

The useful part is that the breakpoints differ per property. Scale peaks at 0.3 and shrinks the rest of the way. Rotation finishes at 0.3 and never moves again. Opacity only cares about the first and last five percent. Four different shapes on one clock — and they can't drift apart, because only one thing is actually moving.

Worth flagging: this uses translateX and translateY rather than left and top. They look interchangeable and aren't. Position is layout; transform isn't. We'll come back to why that matters at the end.

The path is two straight segments, because a single line from finger to icon has no throw in it:

And then the timing, which is where it stops feeling like a tween:

180ms up, 90ms of nothing, 270ms down. The heart covers 30% of its distance in 40% of its time, waits, then accelerates into the corner. Ease-out going up, ease-in coming down — it's never travelling at a constant speed, for the same reason nothing thrown into the air ever is.

This next one is the one I'd actually play with. The slider is progress itself: drag it and every property moves together, because they're all reading the same number. Sit around 0.28 and nudge upward — three of them change direction at once. Then press Play and watch the marker on the curve underneath. The flat step is the 90ms hold; the dashed diagonal is what a plain linear tween would have done instead.

rise 180mshold 90msfall 270ms
progress 0.00 · scale 0.00x
One value driving four properties.

Delete the hold and the heart still arrives in the same 540ms. It just stops looking thrown.

Five of them

Instagram's real like doesn't fly to the icon at all. It sprays hearts up and off the top of the screen. Once there's a single progress value driving a position function, that's a different position function and nothing else.

Two constraints are worth knowing here.

The dice get rolled once, at spawn, and the result is handed over as plain numbers. The position function runs on the UI thread on every frame and has to return the same point for the same progress — a Math.random() inside it puts the heart on a fresh curve sixty times a second.

And each heart removes itself when its own animation reports that it finished:

It's tempting to use a setTimeout for this instead. Don't. A timer and an animation that start together drift apart the moment either is interrupted, and you get hearts that vanish mid-flight or linger after they're invisible.

Double-tap a few times in quick succession below, then turn the paths on. The variation is the whole effect — five hearts on one curve is a queue, five on five curves is a burst. Notice also that nothing accumulates: tap as fast as you like and the number in flight stays bounded, because each one cleans up after itself.

443K
2,679
8,314
instagramFollow
shredding in Peruvian sand
Add comment...
double-tap the video
Five hearts, five curves.

Doing this on the web

Every demo on this page is a browser reimplementation, so the mapping is worth writing down. It's closer than it has any right to be.

A shared value becomes a MotionValue. Both are a number that lives outside React and can be read by a render loop without causing a render. Note that framer's x and y style shorthands compile to transform, not to left and top — the same choice, made for you.

withSequence becomes keyframes with times. The repeated 0.3 is the hold — a keyframe that doesn't move across a slice of the timeline is withDelay in a different grammar.

onLayout becomes getBoundingClientRect, and gets easier: DOM rects are viewport-absolute, so there are no parent offsets to add back. It gets harder in one specific way — rects are reported after transforms, so anything measured inside a scaled container has to be divided by that scale. Measure on mount and on resize; never per frame.

numberOfTaps(2) becomes your own detector. This is the one place the web gives you nothing usable. dblclick exists, but its interval belongs to the operating system, it has no notion of how far apart the two taps were, and it isn't dispatched at all by some mobile browsers.

Both panes below are listening for a double click. Click the left one twice slowly — around 300 to 400ms apart — and it still fires, because that's the system's window and not yours. The right one holds a 250ms budget and treats the same pair as two separate taps, which is exactly the distinction between liking a post and not meaning to.

double-click here
dblclick — interval set by the OS
 
double-click here
250ms + 28px — interval and slop are yours
 
dblclick against a 250ms detector.

Blocking gestures become closest(). Mark the exceptions in the markup and reject them in the handler — the same declaration, in a different grammar again:

Which leaves the part that doesn't map at all.

Reanimated runs animations on a separate thread, so a slow render can't stutter a heart already in flight. On the web everything shares one thread, and the closest equivalent is a discipline: animate only transform and opacity, because those are the two things the compositor can change without asking the main thread anything at all.

The demo below runs the same flock two ways off the same loop, writing straight to the DOM in both cases. The only difference is which property changes. Push the count up and switch between them — on my machine they separate somewhere around 200 hearts, and left/top is the one that falls over, because every frame invalidates layout for the whole document.

120
measuring…
The same flock, moved two ways.

It's a real advantage and a fragile one. A single getBoundingClientRect in the middle of a flight, and you've handed all of it back.