webdevpuneet.com

Popmotion Drag Inertia Card — Momentum Drag Snippet

Popmotion Drag Inertia Card · Cards · Plain HTML, CSS & JS · Live preview

CategoryCards

Copy & paste

Source Code

Requires
<div class="pdc-stage">
  <div class="pdc-head">
    <span class="pdc-tag">Popmotion · decay</span>
    <h2>Drag &amp; Throw</h2>
    <p>Drag the card and release with momentum — it keeps sliding and coasts to a stop, or snaps back if you fling it out of bounds.</p>
  </div>
  <div class="pdc-track" id="pdcTrack">
    <div class="pdc-card" id="pdcCard">
      <span class="pdc-emoji">🎯</span>
      <span class="pdc-label">Throw me</span>
      <span class="pdc-vel" id="pdcVel">v: 0.00</span>
    </div>
  </div>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

Real velocity tracking
Pointer speed is computed from displacement over time on every move event, not estimated after the fact.
Decay-based momentum
Popmotion animate({ type: "decay" }) simulates exponential friction from the release velocity.
Mid-flight bounds correction
Decay is interrupted and replaced with a spring animation the instant the card would leave bounds.
Independent axis physics
X and Y each run their own decay animation seeded with that axis velocity component.
Spring snap-back
Out-of-bounds correction uses a spring, not a linear tween, for a physical bounce-back feel.
Pointer Events API
Uses setPointerCapture so drags track correctly even if the pointer leaves the card.
Live velocity readout
A small label shows the current drag speed for tuning and demonstration.
No CSS transitions
Every motion frame is driven by JS onUpdate, so it composes cleanly with interrupts.

About this UI Snippet

Popmotion Drag Inertia Card — Real Momentum from Pointer Velocity

Screenshot of the Popmotion Drag Inertia Card snippet rendered live

A drag interaction that stops dead the instant you release the mouse feels wrong, because nothing in the physical world does that. Momentum scrolling, thrown objects, flicked cards — they all keep moving after the force stops, decelerating gradually. This snippet reproduces that with Popmotion's animate() decay type, which is a small physics simulation rather than a fixed-duration tween.

Tracking velocity yourself, because the browser doesn't

Pointer events give you position, not velocity. To know how fast the card was moving at the moment of release, this snippet keeps a rolling lastPointer record — the previous pointer position and a timestamp — and on every pointermove computes:

velocity.x = ((e.clientX - lastPointer.x) / dt) * 1000;

That's displacement divided by elapsed time, scaled to pixels-per-second. It's recomputed on *every move event*, so by the time pointerup fires, velocity reflects the most recent flick, not an average over the whole drag — flick the card fast at the end of a slow drag and it still throws fast.

Popmotion's decay animation

popmotion.animate({ keyframes: [pos.x], velocity: velocity.x, type: 'decay', power: 0.8, timeConstant: 350, ... })

Decay is Popmotion's model of exponential friction — the same math behind native momentum scrolling. Instead of easing between two fixed keyframes over a fixed duration, it starts from a position and an initial velocity and lets the object glide, decelerating continuously until it drops below restSpeed. Two parameters shape the feel:

- power controls the total distance traveled before the animation settles — higher power means the same initial velocity carries the card further. - timeConstant controls how quickly the deceleration curve bends — a smaller value stops faster (heavier "friction"), a larger value glides longer.

There's no duration to set, because decay doesn't have a fixed endpoint: it's driven by physics parameters, and the actual settle time falls out of the simulation.

Bounds enforcement mid-decay

The onUpdate callback runs on every animation frame and clamps the reported position into the track's bounds before applying it to the DOM. The moment the *unclamped* value would exceed a bound, snapBack() is triggered: it immediately stops the decay animation (activeAnim.stop()) and starts a spring animation from the current position back to the nearest in-bounds point. Using type: 'spring' here rather than a linear or eased tween means the correction itself has a bit of physical overshoot-and-settle rather than sliding back mechanically — it reads as the card "bouncing" off an invisible wall.

Why two separate animations for x and y

Decay's parameters (power, timeConstant, velocity) are scalar, one-dimensional. Diagonal throws are handled correctly here by running two independent animate() calls, one per axis, each seeded with that axis's own velocity component — so a throw that's mostly horizontal decays differently on x than on the smaller y component, exactly matching how the flick actually happened.

Reusing it

This pattern — track pointer velocity, hand it to animate({ type: 'decay' }) on release, clamp with a spring correction — is the backbone of any "throwable" UI: image carousels, bottom sheets, a Popmotion Swipe-Dismiss Stack. Swap the spring-back for a full off-screen fly-away and you have that exact snippet.

Step by step

How to Use

  1. 1
    Add the Popmotion CDNInclude the popmotion UMD build — it exposes a global popmotion object with animate().
  2. 2
    Paste HTML, CSS, and JSA draggable card renders centered in a bounded track.
  3. 3
    Drag the cardPointer events update its position directly and a rolling velocity estimate is recomputed each move.
  4. 4
    Release with a flickpopmotion.animate({ type: "decay" }) takes over, coasting the card using the release velocity.
  5. 5
    Throw it out of boundsonUpdate detects the overshoot mid-decay and switches to a spring animation back inside.
  6. 6
    Tune the feelAdjust power and timeConstant to make throws travel further or stop sooner.

Real-world uses

Common Use Cases

Throwable card decks
Build swipe stacks and any card that should keep moving after release, with Popmotion decay simulating exponential friction from the measured pointer speed.
Bottom sheets and drawers
Give mobile-style panels momentum, with a mid-flight bounds correction that replaces decay with a spring if the card is thrown out of bounds.
Draggable canvas widgets
Create free-floating panels and mini-maps whose X and Y axes each run their own decay animation seeded with that axis velocity.
Physical-feeling prototypes
Let design prototypes feel real without a physics engine, tracking velocity from displacement over time on every pointer move.
Carousel momentum learning
Study a building block for carousels that keep gliding after a flick, and as a clear reference for decay-based physics animation.

Got questions?

Frequently Asked Questions

Velocity is recalculated on every pointermove as (change in position) / (change in time) since the last move event, scaled to px/s. Using total drag distance over total drag time would average out fast flicks with slow starts -- a slow drag ending in a quick flick would report low average speed and throw weakly, which feels wrong. Recomputing per-move captures only the most recent motion.

power scales how far the animation travels in total before it settles -- think of it as inversely related to friction strength. timeConstant controls the shape of the deceleration curve in milliseconds -- roughly, how long it takes the velocity to fall to about a third of its starting value. Lower timeConstant stops sooner; higher power (at the same velocity) travels further.

Popmotion's decay type operates on a single scalar keyframe with a single velocity value. A diagonal throw has different velocity components on each axis, so it needs two independent decay simulations, each seeded with that axis's own velocity, to decelerate realistically rather than moving in a straight diagonal line regardless of the actual flick angle.

The decay animation's onUpdate callback checks the unclamped position every frame. The instant it would exceed a bound, the code calls activeAnim.stop() to cancel the decay immediately and starts a new spring animation from the current (just-over-the-line) position back to the nearest in-bounds point -- there is no gap frame, so the handoff between decay and spring is continuous.

Yes -- apply additional transforms (scale, rotate) inside the same pointermove/onUpdate handlers alongside translate, typically driven by drag distance or velocity magnitude, and Popmotion animate() calls can run in parallel for those properties too, each with its own type (spring, decay, or a fixed tween).

Keep pos and velocity in a ref (not state, to avoid re-renders on every pointermove) and apply the transform imperatively to a DOM ref in the move handler, exactly as this snippet does. Call popmotion.animate() from the pointerup handler the same way; there is nothing framework-specific about Popmotion itself since it operates directly on values you feed into onUpdate.