GodUIGodUI
129Follow on X

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.

01/03One offset, five properties
Edge
−2
Neighbor
−1
Front
0
Neighbor
+1
Edge
+2
tsx
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);
01

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.

tsx
 
<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.

02/03The motion: pan rewrites active, a spring settles it
Pointer
pan · active ← x / 120
Active
spring 320 / 32 settles
Flick ghost
|vx| > 500 · overshoot, then +1
tsx
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));}};
02

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.

tsx
 
<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}
/>
03/03Result
Aurora

Aurora

Basalt

Basalt

Cirrus

Cirrus

Drift

Drift

Ember

Ember

3 / 5
03

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.

tsx
 
animate={{ x, y, rotate: reduce ? 0 : a * 0.5, scale, opacity }}

Motion Score

Orbit CarouselCC: Paint-triggering
SscaleScale spring / press
SopacityFade / cross-fade
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 →