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 positions all five slides with a single number per slide
(offset = i - active) fed through one arc formula. A drag keeps rewriting
that number 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). Opacity has more room before it starts dimming, so a slide is visibly
smaller before it's visibly faded. The result is a deliberate two-stage
falloff.
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 moves the DOM;
the pointer only changes the spring's target. Release past the fling
threshold and active gets one more nudge before the spring 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 same state.
Every property OrbitItem animates (x, y, rotate, scale, opacity)
is a transform or opacity. width/height/marginLeft are set
directly in the style object once and never touched again. Five slides
re-animating on every pointer move never trigger a 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
The wheel is one offset, one formula, and one spring chasing whatever
active the drag or the keyboard last set.
- 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 positions all five slides with a single number per slide
(offset = i - active) fed through one arc formula. A drag keeps rewriting
that number 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). Opacity has more room before it starts dimming, so a slide is visibly
smaller before it's visibly faded. The result is a deliberate two-stage
falloff.
- 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 moves the DOM;
the pointer only changes the spring's target. Release past the fling
threshold and active gets one more nudge before the spring 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 same state.
Every property OrbitItem animates (x, y, rotate, scale, opacity)
is a transform or opacity. width/height/marginLeft are set
directly in the style object once and never touched again. Five slides
re-animating on every pointer move never trigger a 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
The wheel is one offset, one formula, and one spring chasing whatever
active the drag or the keyboard last set.
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