MynduiMyndui
0

Anatomy of the Inertia Gallery

How a throwable gallery gets momentum, elasticity, and distance-based scale/opacity/blur from a single motion value, and why none of it triggers a React re-render while it's moving.

01/04One track, one motion value
Viewport
overflow-hidden, mask-image fade
Track
one flex row, drag="x"
Centered slide
distance-from-center is 0
tsx
const x = useMotionValue(-start * pitch);<div className="overflow-hidden [mask-image:linear-gradient(to_right,transparent,black_8%,black_92%,transparent)]"><motion.div  style={{ x, gap, paddingLeft: pad, paddingRight: pad }}  drag="x"  dragConstraints={{ left: -(count - 1) * pitch, right: 0 }}  dragElastic={0.16}  onDragEnd={handleDragEnd}/></div>
01

One track, one motion value

InertiaGallery is one draggable motion.div and one MotionValue. Every slide's scale, opacity, and blur are useTransform chains reading that same value — there's no per-slide state, no onDrag re-render loop, and no manual momentum math. Framer's own animation loop drives all of it.

The whole strip is a single flex row wrapped in a masked, overflow-hidden viewport. x is the one MotionValue everything downstream reads — dragging the track never touches React state:

The viewport pads each side by half the free space (measured with a ResizeObserver), which is what lets the first and last slide land dead center instead of pinned to an edge.

02/04The throw
rest
thrown
overshoot
settle
0%
32%
55–74%
100%
tsx
const SETTLE_SPRING = { type: "spring", stiffness: 320, damping: 32, mass: 0.9 };const handleDragEnd = () => {if (snap) goTo(nearest());else commit(nearest());};
02

The throw

dragElastic: 0.16 lets the track travel a little past its constraints while you're still holding it — a small give, not a hard wall. Release, and onDragEnd either commits the nearest slide (commit) or, with snap, animates x to it on a spring:

Stiffness 320 with damping 32 is tuned to slightly underdamp — the strip overshoots the target by a few pixels before it settles, rather than arriving dead-on. mass: 0.9 keeps that overshoot small; it's felt, not seen.

03/04The falloff
Scale
1 − distance × 0.14 × falloff
Opacity
clamped 0.4–1
Blur
0 when reduced-motion
tsx
const nd = useTransform(x, (xv) =>Math.min(Math.abs(offset + xv) / pitch, 2.4),);const scale = useTransform(nd, (d) => 1 - d * 0.14 * falloff);const opacity = useTransform(nd, (d) => clamp(1 - d * 0.3 * falloff, 0.4, 1));const blurPx = useTransform(nd, (d) => (reduce ? 0 : Math.min(d * 3.2, 5) * falloff));
03

The falloff

Every slide runs the same three-curve chain off its own distance from center, computed from the shared x value with useTransform — nothing here is per-slide state, it's a pure function re-evaluated on every frame x changes:

Because scale, opacity, and filter are all compositor properties, Framer can update every slide's falloff every frame without asking the browser to lay anything out — the whole strip can be dragged at 60fps+ regardless of how many slides are off-screen.

Nothing here runs a requestAnimationFrame loop of its own. useTransform only recomputes when its input MotionValue changes, and x only changes while a drag or a settle animation is in flight — a gallery sitting still costs nothing beyond the one ResizeObserver watching the viewport.

04/04Result
DuneDune
HarborHarbor
RidgeRidge
BloomBloom
QuartzQuartz
3 / 5
04

The result

One motion value, three distance curves, one settle spring — throw the strip above, or use the buttons.

Accessibility

The viewport is a labelled role="group" with aria-roledescription="carousel"; / on the viewport and the prev/next buttons both call the same goTo, so keyboard and pointer paths always land on the same index. useReducedMotion forces falloff to 0 — scale, opacity, and blur all flatten to their resting values, and the strip becomes a plain scrollable row with the spring/elastic feel switched off.

Motion Score

Inertia GallerySSCompositor-only
SscaleScale spring / press
SopacityFade / cross-fade
SfilterFilter (blur / brightness)
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →