// Notes
Header image for Swipe Gesture Intent as a Normalized Boundary Problem

Swipe Gesture Intent as a Normalized Boundary Problem

I opened PR #34 against enzomanuelmangano/demos, a React Native demo collection by Enzo Mangano. The change itself was merely algorithmic, but it touched one of those details users notice immediately: swipe cards should understand intent, not force you into a long ceremonial drag every single time.

The problem in the existing swipe-cards demo was simple. The card mostly cared about translation distance. If you dragged it far enough, the card would advance. If you made a short but very deliberate flick, it often would not.

The problem with distance-only swipe logic

Distance-only swipe logic has a blind spot: it treats gesture intent as if it were purely spatial. But on a touchscreen, speed is intent too.

When somebody flicks a card, they are not trying to communicate "I have moved this object by exactly N pixels." They are communicating "please get this thing out of my way" and doing so with velocity. If the component ignores that velocity, the interaction can feel weirdly stubborn.

Green vs. Red Flag Quiz

The card swiping quiz lives from a natural swiping interaction in three different directions.

Normalizing translation and velocity

The core idea in the PR was to let translation and velocity contribute together to the decision.

First, both values are normalized into the same signed range of [-1, 0, 1]. Translation is mapped against a horizontal threshold of [-width / 3, 0, width / 3] (width = screen width). Velocity is mapped against a derived threshold based on how fast a finger would need to move to cross that same translation range in 0.1 seconds.

That second part is a nice detail because it turns velocity from a magic number into something tied to screen size and expected gesture duration:

const width = screenWidth // current screen width
const inputTranslationRange = [-width / 3, 0, width / 3]
const FLICK_DURATION = 0.1 // in s
const inputVelocityRange = [
  inputTranslationRange[0] / FLICK_DURATION,
  0,
  inputTranslationRange[2] / FLICK_DURATION,
]

Now translation and velocity components live both in a normalized space. Combining them becomes much easier and much less guess-work.

The blue circle from my highly rigorous research lab

Because the card was also tinted red or green based on where the swipe was heading, I wanted a continuous decision value, not just a binary yes/no threshold. That pushed me toward a geometric boundary instead of a pile of conditionals.

The implementation computes a signed decision value like this:

const signedNormalizedDecisionRadius
  = Math.sign(translateX.value)
    * (normalizedTranslationX * normalizedTranslationX
      + normalizedVelocityX * normalizedVelocityX)

Then it interpolates that value to decide whether to keep the current card active or move to the next one.

Hand-drawn sketch of swipe translation and velocity with a circular boundary in blue and a linear boundary in yellow
The scientifically airtight PR sketch: blue for the chosen circular boundary, yellow for the L1 alternative, red for the "keep the card" area, and outside of that red-marked circle, as the green arrows insist, we swipe it away.

What this effectively does is use the unit circle equation x^2 + y^2 = 1 as the decision boundary. If the normalized translation and normalized velocity stay inside that boundary, we do not advance. Once they cross it, we do.

Strictly speaking, the variable name is a little cheeky because this is not the radius but the squared radius. We do not need the square root because comparing against 1 works just as well and keeps the calculation simpler: A nice side effect from normalization earlier.

Why a circular boundary feels better

I briefly considered an L1-style boundary, something like:

Math.abs(normalizedTranslationX) + Math.abs(normalizedVelocityX)

That would also combine both signals, but the circular version has a nicer property for this use case: on the extremes it rewards whichever signal is dominant a bit more naturally.

A very fast flick with little travel can still count. A slow but committed drag can also count. Neither translation nor velocity is forced to "win" on its own, yet both always contribute.

One nice side effect of open source PRs

After it was merged, Enzo mentioned that rotation might also be better modeled with atan instead of the current linear interpolation. I thought that was a very good observation. The swipe-threshold change solved one layer of naturalness, but it also made the next interesting detail more visible. I'll try to make more time for contributing to open source work.


Found an issue or want to have a chat? Please shoot me a quick email
!
Photo of Lukas
Written by Lukas Werner

Former iPhone hacker turned product-minded developer. I've scaled startups, blocked supercomputers with fluid simulations, and helped digitize governments. Currently crafting user experiences people actually love from Barcelona.