MynduiMyndui
0

Anatomy of the Card Swap

How a 3D card stack cycles through a showcase loop with one array rotation, a shared spring, and a pointer-driven tilt — no per-card timers.

01/04Rank, not position
rank 0 (front)
x 0 · y 0 · scale 1 · rotate 0°
rank 1–3 (back)
each step: +22px x, −28px y, −0.06 scale, −2.5°
tsx
animate={{x: r * offsetX,y: -r * offsetY,scale: 1 - r * scaleStep,rotateZ: r * -2.5,opacity: r > 3 ? 0 : 1,}}
01

Rank, not position

A stack that fans out and cycles through itself looks like it needs one timeline per card. It doesn't. CardSwap keeps a single order array; every card reads its own position out of it and springs there.

Nothing about a card's depth lives on the card. CardSwap keeps one order array — order[r] is the item index currently at rank r, 0 being the front — and derives each card's rank by inverting it:

tsx
 
const rankOf = React.useMemo(() => {
  const m = new Array<number>(n);
  order.forEach((itemIndex, rank) => {
    m[itemIndex] = rank;
  });
  return m;
}, [order, n]);

That one number then drives four transforms at once — x, y, scale, rotateZ all step together as rank increases, so the whole fan reads as one consistent recession into the stack.

02/04The swap is an array rotation

setOrder(o => [...o.slice(1), o[0]])

Front → back
one spring to rank n−1, not a fly-around
Everyone else
rank − 1 · stiffness 320 / damping 32
tsx
const advance = React.useCallback(() => {setOrder((o) => (o.length ? [...o.slice(1), o[0] as number] : o));}, []);React.useEffect(() => {if (!interval || paused || n < 2) return;const t = setInterval(advance, interval);return () => clearInterval(t);}, [interval, paused, n, advance]);
02

The swap is an array rotation

Advancing doesn't tween anyone's position directly — it rewrites order so the front item's index moves to the back, and lets every card's rank (and therefore its animate target) fall out of that new array on the next render:

Because every card's animate prop is just "whatever rankOf[i] says right now," Framer's own diffing handles the transition — there's no manual tween between old and new slot, just a spring (stiffness 320 / damping 32 / mass 0.9) chasing a new target.

03/04Why the whole stack tilts, not each card

useSpring({ x: -py * 12, y: px * 14 }, { mass: 0.1, stiffness: 170, damping: 12 })

Pointer map
position inside the box → [-0.5, 0.5] per axis
Spring
mass 0.1 · stiffness 170 · damping 12
tsx
const handleMove = (e: React.PointerEvent<HTMLDivElement>) => {const rect = ref.current?.getBoundingClientRect();if (!rect) return;const px = (e.clientX - rect.left) / rect.width - 0.5;const py = (e.clientY - rect.top) / rect.height - 0.5;setTilt({ x: -py * 12, y: px * 14 });};<motion.divclassName="[transform-style:preserve-3d]"animate={{ rotateX: tilt.x, rotateY: tilt.y }}transition={{ type: "spring", stiffness: 170, damping: 12, mass: 0.1 }}>{/* every card, already offset by its own rank */}</motion.div>
03

Why the whole stack tilts, not each card

The pointer parallax lives one level up from the cards — on the [transform-style:preserve-3d] wrapper that holds all of them, not on any individual card:

One spring, applied once, tilts the entire fan together — cards don't fight each other for depth because their individual rotateZ offsets and the group's rotateX/rotateY compose on the same 3D stage.

04/04Result

Realtime sync

Every change lands instantly across every device and teammate.

Built-in analytics

Understand usage without wiring up a single event by hand.

Edge delivery

Served from the region closest to each request, every time.

Audit logs

Know who did what, and when — exportable on demand.

04

The result

One array, one lookup from index to rank, two springs (fast for the tilt, smooth for the re-flow) — that's the entire stack, cycling itself forever.

Accessibility

useReducedMotion is read once, at the top: reduced-motion swaps every card's transition to { duration: 0 } (the position still updates, it just doesn't spring) and short-circuits handleMove before it ever calls setTilt. The prev/next controls are always real <button>s with aria-labels — they work identically whether or not motion is enabled, and they're the only way to drive the stack when interval={0}.

tsx
 
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 320, damping: 32, mass: 0.9 }}

Motion Score

Card SwapSSCompositor-only
SrotateRotation
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 →