TransitionsFree

Inertia Momentum

A 50 px cube you grab with a mouse, a finger or a pen, and throw: released, it keeps the speed of the gesture, slows by 5% each frame and bounces back at half speed off the edges — 2.2 KB of JavaScript, no dependency, left where you drop it when the user reduces motion.

JSPhysiqueDrag

Updated

Hover or click the scene to interact

This effect is free — the Transitions category contains 52 effects total, including 9 free. Effect.Labs has 840 vanilla effects. Explore the category →

Usage examples

Curling night · Les SaisiesEnd 3 · your stone

Grab the stone, swing, let go before the red line

① Throwing a curling stone — a ski resort's mini-game

WhenDuring curling night, the player grabs their stone on the ice, swings and lets go before the red line; it glides and slows down until it stops in the house.
WhyThe strength of the gesture decides the distance: inertia is the whole game, with no power gauge to set.
Settings512 × 266 px ice track, 150 px target; object shaped as a 42 px round stone with a red handle; original friction and bounce (0.95, half speed); demo throw at 13 px per event, stopping 40 cm from the button.
Studio, 4th floor · 24 m²Move on 14 Nov

Grab the sofa and throw it towards a wall

② A sofa slid against a wall — a moving-plan app

WhenOn the studio plan, the user grabs the sofa and pushes it toward the east wall in one gesture; the sofa glides, touches the wall and stops there, and the app shows the space left to walk to the bed.
WhyThe furniture glides and brakes like on a wooden floor; the bounce at the edge shows the limit of the room with no error message.
Settings420 × 260 px room with 7 px walls, plank floor; 100 × 42 px object shaped as a sofa seen from above; original physics, edges computed from the sofa's actual size; throw tuned to reach the wall almost at rest.
Dock 4 · sorting drillScore 1,240
NORTH SOUTH

Parcel N-4417: throw it into its lane

③ A parcel thrown into its lane — a loading-dock training game

WhenDuring the sorting drill, the trainee picks parcel N-4417 off the belt and throws it upward, into the North lane; the score goes up when the parcel stops in the right zone.
WhyThrowing with inertia makes the sorting gesture playful and memorable: you aim, you dose, you watch the parcel arrive.
Settings512 × 270 px dock, two dashed 150 px lanes, striped belt at the bottom; 54 × 42 px parcel; vertical throw (−9 px per event), stopping against the top edge of the lane.

How it works

The .md-inertia-stage frame (250 px tall, background #1a1a24) holds the .md-inertia track, which fills the frame, and the .md-inertia-box object: 50 × 50 px, 10 px corners, an indigo to cyan gradient (#6366f1 → #06b6d4), open-hand cursor. It is centered by the CSS (left and top at calc(50% - 25px)), so it sits in the right place even when the block is hidden on load and shown later. touch-action: none lets a finger grab the object instead of scrolling the page, user-select: none avoids selecting text while dragging it.

The script walks through every .md-inertia on the page and listens to pointer events on the object, which cover mouse, finger and pen. On pointerdown, it reads the object's actual position (offsetLeft, offsetTop) and captures the pointer (setPointerCapture): the object follows even if the gesture leaves it. On each pointermove, the speed is the last movement, in pixels. On pointerup, a requestAnimationFrame loop takes over: the speed is multiplied by 0.95 each frame, the object moves by that much, and stops below 0.1 px per frame. At an edge, the speed reverses and drops by half (vx *= -0.5), and the position is brought back inside the track — limits are computed from the object's actual size, whatever it is. The glide covers about 19 times the release speed: released at 12 px per frame, the object travels about 230 px further.

Measured in Chromium 151, Firefox 153 and WebKit 26, a 72 px throw to the right with a mouse: the object is released 72 px further and glides another 226 px. With a finger (real touch events in Chromium), the same throw stops at exactly the same spot. The distance does not depend on the screen's refresh rate; the duration does: about 1.5 s at 60 frames per second for a 12 px release, half that on a 120 Hz screen. While held, the object is not kept inside the track: dragged past the edge, it comes back on release, reduced motion included, where it is put back against the edge without gliding. The frame has its own class, prefixed like the rest of the effect: two effects from the library pasted on the same page do not overwrite each other.

Accessibility

  • Reduced motion: under prefers-reduced-motion: reduce, the object still follows the hand while held (the motion comes from the user), but no longer glides on release: it stays where it was put down, or against the edge if it was dragged out of the track. Measured in Chromium 151, Firefox 153 and WebKit 26: released at 447 px, still at 447 px two seconds later; dragged 50 px below the bottom of the track, put back against that edge as soon as it is released.
  • Keyboard: the effect only responds to a pointer. If the throw matters (a game, placing furniture), offer another way: "throw hard / soft" buttons, or arrow keys that move the object and Enter to release it at a set speed.
  • Screen readers: the object is a div with no role or text. Announce the result of the gesture as text, in a role="status" element ("Stone in the house, 40 cm from the button"), as the examples do.
  • Touch: touch-action: none only applies to the object; the rest of the track lets the page scroll. Keep the object big enough for a finger (at least 44 px).

Browser compatibility

CSS + 2.2 KB of JavaScript (Pointer Events, setPointerCapture, requestAnimationFrame), no dependency. Measured in Chromium 151, Firefox 153 and WebKit 26: same throw, same glide, same bounce, same hard stop under reduced motion; finger gesture checked in Chromium with real touch events. Pointer Events exist since Chrome 55, Firefox 59 and Safari 13.

Chrome 55+✓ Full
Firefox 59+✓ Full
Safari 13+✓ Full
Edge 79+✓ Full
Mobile iOS 13+✓ Full (finger)
Android Chrome 55+✓ Full (finger)

Without JavaScript, the object stays still in the center of its track: it shows, but cannot be grabbed. Without Pointer Events (browsers older than 2019), same thing: in that case, offer a button action if the gesture is essential.

The code

Copy the three blocks into your page. No dependencies.

HTML
<div class="md-inertia-stage">
  <div class="md-inertia">
    <div class="md-inertia-box"></div>
  </div>
</div>
CSS
.md-inertia-stage,
.md-inertia-stage *,
.md-inertia-stage *::before,
.md-inertia-stage *::after {
  box-sizing: border-box
}

.md-inertia-stage {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 250px;
  overflow: hidden;
  position: relative;
  background: rgb(26, 26, 36);
  color: rgb(255, 255, 255);
  font-family: sans-serif;
}

.md-inertia {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
}

/* Centré par le CSS (et non mesuré au chargement) : juste même si le bloc est masqué au départ.
   touch-action: none laisse le doigt saisir le cube au lieu de faire défiler la page. */
.md-inertia-box {
  width: 50px;
  height: 50px;
  border-radius: 10px;
  background: linear-gradient(135deg, #6366f1, #06b6d4);
  cursor: grab;
  position: absolute;
  left: calc(50% - 25px);
  top: calc(50% - 25px);
  touch-action: none;
  user-select: none;
  transition: none;
}
JavaScript (fx-0729)
(function() {
  // Inertia Momentum : saisir, lancer, l'élément glisse puis ralentit (souris, doigt, stylet)
  var friction = 0.95;
  var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

  document.querySelectorAll('.md-inertia').forEach(function(container) {
    var box = container.querySelector('.md-inertia-box');
    if (!box) return;

    var x = 0,
      y = 0,
      vx = 0,
      vy = 0;
    var isDragging = false,
      lastX = 0,
      lastY = 0,
      raf = 0;

    function place() {
      box.style.left = x + 'px';
      box.style.top = y + 'px';
    }

    box.addEventListener('pointerdown', function(e) {
      isDragging = true;
      cancelAnimationFrame(raf);
      x = box.offsetLeft; // position réelle au moment de la saisie
      y = box.offsetTop;
      lastX = e.clientX;
      lastY = e.clientY;
      vx = 0;
      vy = 0;
      try { box.setPointerCapture(e.pointerId); } catch (err) {}
      box.style.cursor = 'grabbing';
      e.preventDefault();
    });

    box.addEventListener('pointermove', function(e) {
      if (!isDragging) return;
      vx = e.clientX - lastX;
      vy = e.clientY - lastY;
      x += vx;
      y += vy;
      place();
      lastX = e.clientX;
      lastY = e.clientY;
    });

    function release() {
      if (!isDragging) return;
      isDragging = false;
      box.style.cursor = 'grab';
      if (reduceMotion.matches) { // mouvement réduit : pas de glissade, l'élément est seulement ramené dans la piste
        x = Math.max(0, Math.min(container.clientWidth - box.offsetWidth, x));
        y = Math.max(0, Math.min(container.clientHeight - box.offsetHeight, y));
        place();
        return;
      }
      raf = requestAnimationFrame(animate);
    }
    box.addEventListener('pointerup', release);
    box.addEventListener('pointercancel', release);

    function animate() {
      if (isDragging) return;
      if (Math.abs(vx) < 0.1 && Math.abs(vy) < 0.1) return;
      vx *= friction;
      vy *= friction;
      x += vx;
      y += vy;
      var maxX = container.clientWidth - box.offsetWidth;
      var maxY = container.clientHeight - box.offsetHeight;
      if (x < 0 || x > maxX) vx *= -0.5;
      if (y < 0 || y > maxY) vy *= -0.5;
      x = Math.max(0, Math.min(maxX, x));
      y = Math.max(0, Math.min(maxY, y));
      place();
      raf = requestAnimationFrame(animate);
    }
  });
})();

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Friction (JS — var friction = 0.95) 0.95: speed loses 5% per frame 0.98 gives ice (distance ≈ 49 times the release speed); 0.9 a thick carpet (≈ 9 times). Above 0.99, the object crosses the track at the slightest gesture.
Edge bounce (JS — vx *= -0.5, vy *= -0.5) comes back at half its speed -0.8 gives a puck that bounces clearly; 0 sticks the object to the edge, like furniture against a wall.
Stop threshold (JS — 0.1) stops below 0.1 px per frame 0.5 stops the object earlier, without the long almost-still ending; useful to trigger the result (score, message) sooner.
Object size and look (CSS — .md-inertia-box) 50 × 50 px, indigo-cyan gradient Any size works: the script reads offsetWidth and offsetHeight for the edges. If you change the size, update the centering (calc(50% - half)).
Track size (CSS — .md-inertia-stage / .md-inertia) full width, 250 px tall The track can be a floor plan, a field, a shelf: give it its dimensions, the object stays inside at the end of each throw, reduced motion included.
Several objects one object per .md-inertia Duplicate the whole block: each track has its own physics. Several objects in one track require adapting the script (one loop per object), with no collision between them.

FAQ

Does it work with a finger?

Yes. The script listens to pointer events, shared by mouse, finger and pen, and the object has touch-action: none: placed on it, a finger grabs it instead of scrolling the page. Measured in Chromium with real touch events: a 72 px throw ends at the same spot as with a mouse. Elsewhere on the track, the finger scrolls normally.

How do I know where the object stopped?

The glide stops when the speed drops below 0.1 px per frame: that is the line if (Math.abs(vx) < 0.1 && Math.abs(vy) < 0.1) return;. Just before that return, dispatch an event (box.dispatchEvent(new CustomEvent('md-stop'))) and read box.offsetLeft / offsetTop in your page: that is where you count points or show the message.

Why does the object glide faster on some screens?

Friction applies each frame: on a 120 Hz screen there are twice as many frames per second, so the glide lasts half as long. The distance covered does not change (it is the sum of a series that does not depend on the pace). For the same duration everywhere, multiply the speed by Math.pow(0.95, dt / 16.7), where dt is the time elapsed since the previous frame.