Grab the stone, swing, let go before the red lineIn the house! 40 cm from the button · 2 points
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.
Updated
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
Grab the sofa and throw it towards a wallAgainst the east wall · 1.20 m left to walk past the bed
Parcel N-4417: throw it into its laneNorth lane ✓ +50 points
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
divwith no role or text. Announce the result of the gesture as text, in arole="status"element ("Stone in the house, 40 cm from the button"), as the examples do. - Touch:
touch-action: noneonly 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.
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.
<div class="md-inertia-stage">
<div class="md-inertia">
<div class="md-inertia-box"></div>
</div>
</div>
.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;
}
(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 / property | Default | Effect |
|---|---|---|
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.