Anatomy of Orbit Carousel
How one signed offset drives position, tilt, scale, and opacity for every slide on the arc, and how a drag rewrites that offset live before a spring settles it.
↓Scroll to step through it
One offset, five properties
Orbit Carousel doesn't animate five slides independently — it derives all
five from a single number per slide (offset = i - active) fed through one
arc formula. Here's that formula, and the drag that keeps rewriting it in
real time.
Each slide reads its own offset and turns it into angle = offset * angleStep. Everything else — x, y, scale, opacity, rotate — is a
function of that one angle. The front slide (offset = 0) sits full-size and
upright; every step away curves along the arc, shrinks, dims, and tilts.
<motion.div
animate={{ x, y, rotate: reduce ? 0 : a * 0.5, scale, opacity }}
transition={MORPH_SPRING}
/>scale and opacity fall off on their own curves (denominators 70 and
82), not a shared one — opacity has more room before it starts dimming, so a
slide is visibly smaller before it's visibly faded. That's a deliberate
two-stage falloff, not one number reused twice.
The motion: pan rewrites active, a spring settles it
Dragging doesn't move any slide directly. onPan recomputes active on
every pointer move — goTo(dragFrom - offset.x / 120) — and every slide's
animate re-targets from that new offset. Framer's spring is what actually
moves the DOM; the pointer just keeps changing where the spring is chasing.
Release past the fling threshold and active gets one more nudge before the
spring even starts settling toward it.
Because active is a plain rounded integer even mid-drag, every slide is
already animating toward its next resting angle as soon as the pointer
crosses the halfway point to the next slot — there's no separate "commit"
step; the drag handler and the settle spring share the exact same state.
Every property OrbitItem animates — x, y, rotate, scale, opacity
— is a transform/opacity pair, set directly in the style object as
width/height/marginLeft once and never touched again. Five slides
re-animating on every pointer move never triggers a single layout
recalculation; the browser only repaints transformed layers.
<motion.div
className="absolute top-0 left-1/2"
style={{ width, height, marginLeft: -width / 2, zIndex: Math.round(300 - abs) }}
animate={{ x, y, rotate, scale, opacity }}
transition={MORPH_SPRING}
/>The result
One offset, one formula, one spring chasing whatever active the drag or
the keyboard last set — that's the entire wheel.
- Edge
- −2
- Neighbor
- −1
- Front
- 0
- Neighbor
- +1
- Edge
- +2
const a = offset * angleStep;const rad = (a * Math.PI) / 180;const x = radius * Math.sin(rad);const y = radius * (1 - Math.cos(rad));const scale = 1 - Math.min(Math.abs(a) / 70, 1) * 0.42;const opacity = Math.max(0, 1 - Math.min(Math.abs(a) / 82, 1) * 0.85);Anatomy of Orbit Carousel
How one signed offset drives position, tilt, scale, and opacity for every slide on the arc, and how a drag rewrites that offset live before a spring settles it.
- Edge
- −2
- Neighbor
- −1
- Front
- 0
- Neighbor
- +1
- Edge
- +2
const a = offset * angleStep;const rad = (a * Math.PI) / 180;const x = radius * Math.sin(rad);const y = radius * (1 - Math.cos(rad));const scale = 1 - Math.min(Math.abs(a) / 70, 1) * 0.42;const opacity = Math.max(0, 1 - Math.min(Math.abs(a) / 82, 1) * 0.85);One offset, five properties
Orbit Carousel doesn't animate five slides independently — it derives all
five from a single number per slide (offset = i - active) fed through one
arc formula. Here's that formula, and the drag that keeps rewriting it in
real time.
Each slide reads its own offset and turns it into angle = offset * angleStep. Everything else — x, y, scale, opacity, rotate — is a
function of that one angle. The front slide (offset = 0) sits full-size and
upright; every step away curves along the arc, shrinks, dims, and tilts.
<motion.div
animate={{ x, y, rotate: reduce ? 0 : a * 0.5, scale, opacity }}
transition={MORPH_SPRING}
/>scale and opacity fall off on their own curves (denominators 70 and
82), not a shared one — opacity has more room before it starts dimming, so a
slide is visibly smaller before it's visibly faded. That's a deliberate
two-stage falloff, not one number reused twice.
- Pointer
- pan · active ← x / 120
- Active
- spring 320 / 32 settles
- Flick ghost
- |vx| > 500 · overshoot, then +1
const handlePan = (_e: unknown, info: PanInfo) => {goTo(Math.round(dragFrom.current - info.offset.x / 120));};const handlePanEnd = (_e: unknown, info: PanInfo) => {if (Math.abs(info.velocity.x) > 500) { goTo(active - Math.sign(info.velocity.x));}};The motion: pan rewrites active, a spring settles it
Dragging doesn't move any slide directly. onPan recomputes active on
every pointer move — goTo(dragFrom - offset.x / 120) — and every slide's
animate re-targets from that new offset. Framer's spring is what actually
moves the DOM; the pointer just keeps changing where the spring is chasing.
Release past the fling threshold and active gets one more nudge before the
spring even starts settling toward it.
Because active is a plain rounded integer even mid-drag, every slide is
already animating toward its next resting angle as soon as the pointer
crosses the halfway point to the next slot — there's no separate "commit"
step; the drag handler and the settle spring share the exact same state.
Every property OrbitItem animates — x, y, rotate, scale, opacity
— is a transform/opacity pair, set directly in the style object as
width/height/marginLeft once and never touched again. Five slides
re-animating on every pointer move never triggers a single layout
recalculation; the browser only repaints transformed layers.
<motion.div
className="absolute top-0 left-1/2"
style={{ width, height, marginLeft: -width / 2, zIndex: Math.round(300 - abs) }}
animate={{ x, y, rotate, scale, opacity }}
transition={MORPH_SPRING}
/>Aurora
Basalt
Cirrus
Drift
Ember
The result
One offset, one formula, one spring chasing whatever active the drag or
the keyboard last set — that's the entire wheel.
Accessibility and reduced motion
The stage is role="group" with aria-roledescription="carousel", and the
prev/next buttons duplicate the drag and keyboard affordances for anyone who
can't or doesn't want to drag — ←/→ work whenever a
control has focus. Under prefers-reduced-motion, rotate drops to 0 on
every slide — the arc still curves in x/y, but nothing tilts.
animate={{ x, y, rotate: reduce ? 0 : a * 0.5, scale, opacity }}Motion Score
scaleScale spring / pressopacityFade / cross-fade