# A 3D React carousel that never re-renders while it moves: physics, a seamless loop and lessons

> How Dynamic Carousel drives every card from one motion value: flick physics, a loop without clones, a 3D first paint that needs no script, and the useTransform trap Next.js hid from us.

Behind the build, by [Ramin Valadan](https://upfork.dev/developers/ramin-valadan), about [Dynamic 3D Carousel for React & Next.js — Draggable Image & Video Slider](https://upfork.dev/products/dynamic-3d-carousel-react) (Component, comes with the Pro plan). Published 2026-10-26. 11 min read. Written with AI: Claude Opus 5.5, through Claude.

## What it is

Dynamic Carousel is an editorial carousel for React: photo, video and typographic cards on a shallow 3D arc. Cards follow the finger 1:1, a flick carries them with weight and always lands on a card, the loop has no seam at any speed, cards lean into fast motion and settle with a small overshoot, and the active card opens into a detail view it morphs into. It runs on its own small physics engine built on motion values, and React never renders while it moves.

[Live preview of Dynamic 3D Carousel for React & Next.js — Draggable Image & Video Slider](https://upfork.dev/products/dynamic-3d-carousel-react)

This article explains how it works, for anyone (person or AI) building a carousel or any gesture-driven component in React: one number drives everything, a velocity tracker and a projection decide where a throw lands, the loop wraps each card on its own instead of cloning slides, the first paint is laid out in CSS so the server and the browser agree, and a few things about motion and the browser that surprised us. The component is 13.3 kB gzipped, made 0 React commits across a drag, 20 wheel events, four key presses and 9.5 s of autoplay, and scores 90–95 / 100 / 100 / 100 in Lighthouse on desktop, with LCP at 1.1 s and CLS 0.

## One number drives everything

The whole carousel is one motion value: `offset`, the track's position in px. Every card works out its own distance from the anchor, d in cards, and its whole pose from d: where it sits, how far it turns and steps back, its scale, how much it dims, how far its caption has risen and how far its picture has slid inside the frame. A drag sets `offset`; a snap animates it; autoplay, the arrows, the keys and the wheel all animate the same value with the same spring, so every movement feels like the same object.

The resting pose on the arc, for a distance d and an arc strength `depth`:

`components/dynamic-carousel/engine/pose.ts`, lines 47–59:

```ts
/**
 * The card's resting pose on the arc for distance d (cards from the anchor) and arc strength `depth`:
 * neighbours turn towards the viewer and step back, so the row reads as a shallow curve, not a coverflow.
 */
export function arc(d: number, depth: number) {
  const a = Math.abs(d);
  return {
    rotateY: clamp(d, -3, 3) * -9 * depth,
    z: -Math.min(a, 4) * 90 * depth,
    scale: 1 - Math.min(a, 3) * 0.045 * depth,
    dim: Math.min(a, 2) * 0.18 * depth,
  };
}
```

At the default `depth` of 0.55 the cards next to the active one turn 5° and step back 50 px, and the ones after them come out at about 0.9 of the active card's size: a gentle curve with air in it, not a coverflow.

On top of the pose, cards lean into motion. The lean comes from the track's velocity, passed through a spring of its own:

`components/dynamic-carousel/engine/useCarouselEngine.ts`, lines 661–667:

```ts
  // One object for the component's lifetime, so the memoised cards never re-render because of it.
  const values = useMemo<EngineValues>(
    () => ({ offset, geometry, index, progress, autoplayState, inView, cursorX, cursorY, cursorMode }),
    [offset, geometry, index, progress, autoplayState, inView, cursorX, cursorY, cursorMode],
  );
  const velocity = useVelocity(offset);
  const lean = useSpring(velocity, LEAN_SPRING);
```

That spring (stiffness 260, damping 21) has a damping ratio of about 0.65, so when the cards stop, the lean springs back past upright and settles. That small overshoot is most of why it feels physical. The `values` object is made once for the component's lifetime, so the memoised cards never re-render because of it.

## A loop with no seam and no clones

The usual infinite carousel clones slides at both ends and jumps back when you reach a clone, which shows as a hitch at speed. Here every card wraps on its own:

`components/dynamic-carousel/engine/loop.ts`, lines 10–19:

```ts
/**
 * Where a card sits relative to the anchor, in px, given the track's offset.
 *
 * With `loopLength` the result wraps into [-L/2, L/2): every card moves round the loop on its own, so the loop
 * has no seam at any offset or speed and nothing is ever cloned or jumped back. Without it the track is straight.
 */
export function slotPosition(base: number, offset: number, loopLength?: number): number {
  const x = base - offset;
  return loopLength ? mod(x + loopLength / 2, loopLength) - loopLength / 2 : x;
}
```

A card's position is wrapped into [−L/2, L/2) around the anchor, where L is the loop's length (the number of slides times the step from one card to the next). Each card leaves one side and comes in on the other when its own position crosses the edge, off stage, so no card is ever jumped back in view. When the loop would be shorter than the stage plus a card's margin on each side (a few slides on a wide screen), the slides are laid round it two or more times. Going to a slide takes the shortest way round.

## Throwing: velocity, projection, snap

When the hand lets go, three small pure functions decide where the cards come to rest.

**Velocity.** A tracker keeps the last five pointer samples from the last 100 ms and averages the speed of each step between them, weighting later steps more, so the release speed is what the hand was doing at the end, not over the whole drag. A pointer that stopped more than 60 ms before letting go has no velocity: a drag that stops and lifts lands where it is.

**Projection.** How far a flick would carry on its own:

`components/dynamic-carousel/engine/velocity.ts`, lines 36–43:

```ts
/**
 * How far a flick carries on its own: the distance a body moving at `velocity` (px/ms) covers while it slows by
 * the factor `decel` every millisecond, i.e. v · decel / (1 − decel). The default 0.99644 carries a flick 0.28 s
 * worth of its release speed: a fast flick (3 px/ms) skips two cards, a lazy one lands on the next.
 */
export function projectDistance(velocity: number, decel = 0.99644): number {
  return (velocity * decel) / (1 - decel);
}
```

A body moving at v px/ms that keeps a fraction k of its speed every millisecond covers v·k + v·k² + … = v·k / (1 − k). With k = 0.99644 that is about 280 ms worth of the release speed: a 3 px/ms flick carries about 840 px, two cards at 424 px each.

**Snap.** The landing point is rounded to the nearest card, with two rules:

`components/dynamic-carousel/engine/snap.ts`, lines 16–34:

```ts
/** Below this release speed (px/ms) a gesture is a placement, not a flick. */
export const FLICK_SPEED = 0.3;

/**
 * Where a released gesture comes to rest: the projected landing point, snapped to the nearest card boundary.
 * A flick never travels more than half the slides from where the gesture began, and a quick flick that would
 * round back to its own card moves one card in the direction it was thrown.
 */
export function snapTarget({ offset, startOffset, velocity, step, count, loop }: SnapInput): number {
  const start = Math.round(startOffset / step);
  let target = Math.round((offset + projectDistance(velocity)) / step);
  const cap = Math.max(1, Math.floor(count / 2));
  target = clamp(target, start - cap, start + cap);
  if (target === start && Math.abs(velocity) > FLICK_SPEED && Math.sign(offset - startOffset) === Math.sign(velocity)) {
    target = start + Math.sign(velocity);
  }
  if (!loop) target = clamp(target, 0, count - 1);
  return target * step || 0; // never -0
}
```

No flick travels more than half the slides from where the gesture began, and a quick flick that would round back to its own card moves one card in the direction it was thrown, so a short sharp swipe always does something.

Then the track springs there, carrying the hand's velocity into the spring (px/ms × 1000 = px/s) so there is no stutter between the drag and the animation:

`components/dynamic-carousel/engine/useCarouselEngine.ts`, lines 147–164:

```ts
  /** Animate the track to `to` px; `velocity` (px/ms) carries a release into the spring so nothing stutters. */
  function animateTo(to: number, velocity?: number) {
    anim?.stop();
    target = to;
    const done = () => {
      if (target === to) {
        target = null;
        anim = null;
      }
    };
    anim = animate(
      v.offset,
      to,
      o.reduced
        ? { ...REDUCED_TWEEN, onComplete: done }
        : { ...SNAP_SPRING, ...(velocity === undefined ? {} : { velocity: velocity * 1000 }), onComplete: done },
    );
  }
```

The snap spring is stiffness 170, damping 26, mass 1.1: a damping ratio of 26 / (2√(170 × 1.1)) ≈ 0.95, heavy enough to feel like an object being set down and just short of critical, so a flick carries a hair past and lands without a wobble. With reduced motion the same calls become a 200 ms ease-out tween.

## Taps, drags and pointer capture

A press becomes a drag once it moves 6 px. On touch the carousel only takes a gesture that has gone 8 px and more sideways than up or down; anything steeper scrolls the page (`touch-action: pan-y`). A press while the track is still moving catches it and is never a click. When the press lifts:

`components/dynamic-carousel/engine/useCarouselEngine.ts`, lines 436–459:

```ts
  function release(e: PointerEvent, cancelled: boolean) {
    const gs = gesture;
    if (!gs || e.pointerId !== gs.id) return;
    gesture = null;
    if (track) capture(track, e.pointerId, false);
    if (gs.phase === "pending") {
      // A touch that never travelled: a tap, unless it caught the track mid-flight.
      suppressClick = true;
      if (gs.caught || cancelled) settle();
      else tapSlot(gs.slot);
      return;
    }
    const tap = !cancelled && !gs.moved && !gs.caught && e.timeStamp - gs.t0 < 500;
    // Taps are handled here (pointer capture would send the click to the track, not the card); the click
    // event that follows any press on the track is swallowed.
    suppressClick = true;
    const velocity = cancelled ? 0 : -tracker.velocity(e.timeStamp);
    if (tap) tapSlot(gs.slot);
    else {
      const { step: s } = g();
      animateTo(snapTarget({ offset: v.offset.get(), startOffset: gs.start, velocity, step: s, count: o.count, loop: o.loop }), velocity);
    }
    if (!gs.touch) cursorOver(e);
  }
```

> **Lesson: Pointer capture sends the click to the capturing element: resolve taps from the card under the pointer at pointerdown** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** with the track capturing the pointer for drags, a tap on a card could still be handled by that card's own click handler.
> **What happened:** after `setPointerCapture` on the track, the pointer events that follow, and the click after them, go to the track, not to the card under the pointer, so the click could not tell which card was tapped.
> **The fix:** the gesture records the card under the pointer at `pointerdown`, before capture (`slot: slotAt(e)`). On release, a press that moved less than 6 px and lifted within 500 ms is a tap on that card: the active card opens, any other card is brought to the anchor. The click event that follows any press on the track is swallowed.
> **Evidence:** commit 98e4f3c; `Gesture.slot` and `release` in useCarouselEngine.ts.

## The wheel: never trap the page

Horizontal wheel and trackpad movement drives the cards 1:1 and lands on a card 120 ms after the last event. A vertical wheel over the carousel can move the cards too (`wheel="hijack"`), but never at the page's expense:

`components/dynamic-carousel/engine/useCarouselEngine.ts`, lines 495–503:

```ts
    // Vertical wheels move the cards only when the carousel is (nearly) all in view and the gesture began on it.
    if (vertical && (o.wheel !== "hijack" || wheel.page || v.inView.get() < 0.85)) return;
    const delta = vertical ? dy : dx;
    if (!delta) return;
    const at = v.offset.get();
    // Never trap the page: at the ends of a straight track, and after one full lap of a loop, the page scrolls.
    if (!o.loop && ((delta < 0 && at <= 0.5) || (delta > 0 && at >= maxOffset() - 0.5))) return;
    if (vertical && o.loop && wheel.travel >= (o.count - 1) * g().step - 1) return;
    e.preventDefault();
```

A vertical wheel moves the cards only when at least 85% of the carousel is in view and the gesture began on it: if the page was already scrolling when the carousel arrived under the pointer, it keeps scrolling. The page takes over again at the ends of a straight track, and after one full lap of a loop. A notched mouse wheel moves one card per notch on the same spring as everything else.

## Zero React renders while it moves

Everything per frame is a motion value: the offset, each card's d, transform, visibility, dim, caption reveal and parallax, the lean, the cursor. React renders the cards once and then only on resize or when props change. To check it, a counter on React DevTools' global hook counted commits on the demo: 0 across 9.5 s of autoplay, a drag and snap, 20 wheel events and four key presses. Only transforms and opacity animate; the card's two shadows are baked layers cross-faded by opacity, and `will-change` is set while the track moves and cleared 300 ms after it rests.

Computed motion values made this possible, and they also hid the worst bug of the build. Each card derives its values from the track like this:

`components/dynamic-carousel/CarouselSlideCard.tsx`, lines 382–396:

```tsx
  // Computed motion values subscribe to the values they read on their first run, so each reads all of its inputs
  // before any early return (the first run happens before the stage is measured).
  const d = useTransform(() => {
    const g = geometry.get();
    const at = offset.get();
    if (!g.measured) return k0;
    return slotPosition(slot * g.step, at, g.loopLength || undefined) / g.step;
  });
  const transform = useTransform(() => {
    const g = geometry.get();
    const dd = d.get();
    const v = lean.get();
    if (!g.measured) return cssTransformFor(k0);
    return transformFor(dd * g.step + g.shift, dd, g.depth, v, g.tilt);
  });
```

> **Lesson: motion's function-form useTransform subscribes only to the values its first run reads: an early return froze the cards** (Not in the docs; learned by the AI (Claude Opus 5.5); motion@14)
> **I assumed:** `useTransform(() => …)` recomputes whenever any motion value it reads changes, like a computed signal that collects its inputs on every run.
> **What happened:** each card's distance returned early while the stage was still unmeasured (`if (!g.measured) return k0`), before it read `offset`. Its first run never read `offset`, so it never subscribed to it. In UpFork's live preview, a client-only build whose first render never repeats, the cards stayed frozen in their first layout while the slide counter kept counting. In the Next.js demo it worked, because a second render after hydration collected the inputs again, so the bug never showed locally.
> **The fix:** every computed value reads all of its inputs before any branch (`const g = geometry.get(); const at = offset.get();`, then the early return). A regression test renders once, client-only, and checks that the second card moves from `translate3d(424px…` to `translate3d(0px…` after an arrow key.
> **Evidence:** commit 98e4f3c; the test "moves the cards themselves, in a render that never repeats (a client-only app, UpFork's preview)".

## The first paint, before any script

The server doesn't know how wide the stage is, so it can't compute the arc in px. CSS can: container queries set the step between cards, the anchor's shift and the arc's strength as CSS variables, and the server writes each card's transform in terms of them:

`components/dynamic-carousel/engine/pose.ts`, lines 77–88:

```ts
/**
 * The same pose written with the stylesheet's own variables, for the first paint: the server does not know the
 * stage's width, but CSS does, so the arc is right before any script runs and nothing moves when it does.
 */
export function cssTransformFor(k: number) {
  const a = Math.abs(k);
  return (
    `translate3d(calc(${k} * var(--dc-step) + var(--dc-shift)),0,calc(${r(-Math.min(a, 4) * 90)}px * var(--dc-depth-eff))) ` +
    `rotateY(calc(${r(clamp(k, -3, 3) * -9)}deg * var(--dc-depth-eff))) ` +
    `scale(calc(1 - ${r(Math.min(a, 3) * 0.045, 1000)} * var(--dc-depth-eff)))`
  );
}
```

The arc is right in the server's HTML, before any script runs, and when the engine measures the stage and takes over, nothing moves: CLS 0. Cards more than three from the anchor start hidden.

The pictures nearest the anchor load eagerly with a high fetch priority. How they appear mattered as much as when they load:

`components/dynamic-carousel/CarouselSlideCard.tsx`, lines 79–86:

```tsx
      // First-screen pictures are drawn as soon as they arrive (no fade that would wait for hydration).
      data-loaded={priority ? "" : undefined}
      ref={(el) => {
        if (el?.complete && el.naturalWidth) loaded(el);
      }}
      onLoad={(e) => loaded(e.currentTarget)}
      // A picture that fails leaves its blurred plate, never the browser's broken-image alt text.
      onError={(e) => e.currentTarget.setAttribute("data-error", "")}
```

> **Lesson: A fade-in started from onLoad waits for hydration and delays LCP: render first-screen pictures already marked loaded** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** every picture could start at opacity 0 and fade in from its `onLoad` handler, the first ones included.
> **What happened:** the handler only exists once React has hydrated, so the first-screen pictures, which had usually arrived long before, stayed invisible until it had, and the largest contentful paint waited with them.
> **The fix:** the nearest pictures render with `data-loaded` already in the server's HTML (shown at once by CSS), with `loading="eager"`, `fetchpriority="high"` and synchronous decoding; the rest still fade in. A ref callback marks pictures that finished before hydration. LCP is 1.1 s.
> **Evidence:** commit 98e4f3c; `Picture` in CarouselSlideCard.tsx.

> **Lesson: A picture that fails to load shows the browser's broken-image alt text over the card: hide the img, keep the plate** (The tools and the machine; learned by the AI (Claude Opus 5.5))
> **I assumed:** a picture that failed to load would simply leave its blurred placeholder showing.
> **What happened:** checked through UpFork's preview builder, a failed `<img>` drew the browser's broken-image mark and its alt text across the card, over the placeholder.
> **The fix:** `onError` sets `data-error`, and `.dc-img[data-error] { visibility: hidden; }` leaves the blurred plate. The demos also keep their own background transparent (`--dc-bg: transparent`), so the frame they are shown in shows through without a seam.
> **Evidence:** commit 98e4f3c; styles.css line 218.

## The detail view: a morph with no jump

Clicking the active card opens it into a large view, and the card's face flies there (a FLIP: the large copy starts at the card's rectangle and springs to its own). For the type inside not to jump as it grows, everything in a card is sized in a unit tied to the card's width:

`components/dynamic-carousel/styles.css`, lines 145–148:

```css
/* A unit that is 1px on a 400px-wide card, so everything inside a card scales with it (and with the detail
   view's larger copy, which is why the open morph is seamless). */
.dc-face {
  --dc-u: 0.25cqw;
```

`--dc-u` is a quarter of a container-query percent, 1 px on a 400 px card, so the large copy's type is the same layout scaled up and the morph only scales it. Where the morph starts needs care too:

`components/dynamic-carousel/CarouselDetail.tsx`, lines 18–26:

```tsx
/** Where an element sits without its own transform (the morph's home), whatever the transform is right now. */
function homeRect(el: HTMLElement | null) {
  if (!el) return null;
  const prev = el.style.transform;
  el.style.transform = "none";
  const rect = el.getBoundingClientRect();
  el.style.transform = prev;
  return rect;
}
```

> **Lesson: Measure a FLIP morph's start with transform none, or StrictMode's second run measures an element already moved** (My own mistake; learned by the AI (Claude Opus 5.5); react@19.2)
> **I assumed:** the card's `getBoundingClientRect()` when the view opened was where the morph should start.
> **What happened:** the rectangle includes whatever transform the element carries at that moment. In development, StrictMode runs effects twice, and the second run measured an element the first run had already transformed, so the morph set off from the wrong place.
> **The fix:** `homeRect` takes the element's own transform off, measures, and puts it back, all in one synchronous step, so the morph's home is the same however often the effect runs.
> **Evidence:** commit 98e4f3c; `homeRect` in CarouselDetail.tsx.

Inside the view, ← and → move through the slides and the carousel behind follows, so closing lands on the card you were looking at. Focus is trapped while it is open and given back to the card when it closes.

## Captions you can read on every photo

Titles and captions rise over the active picture on a gradient scrim:

`components/dynamic-carousel/styles.css`, lines 226–236:

```css
  /* Eased stops (no visible band). Strong enough for white captions on the brightest demo photo (4.5:1). */
  background: linear-gradient(
    to top,
    rgb(0 0 0 / 0.78) 0%,
    rgb(0 0 0 / 0.7) 10%,
    rgb(0 0 0 / 0.55) 20%,
    rgb(0 0 0 / 0.34) 32%,
    rgb(0 0 0 / 0.16) 44%,
    rgb(0 0 0 / 0.05) 54%,
    rgb(0 0 0 / 0) 64%
  );
```

> **Lesson: A caption scrim tuned on typical photos fails on white architecture: measure contrast on the brightest picture** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** a scrim that kept white captions readable on the first demo photos would hold for the rest.
> **What happened:** on photos of white architecture the captions fell below 4.5:1.
> **The fix:** the scrim's darkest stop went to 0.78, with eased stops so no band shows where it fades out, and every caption was measured against all 20 demo photos, not judged by eye: at least 4.5:1 on each.
> **Evidence:** commit 98e4f3c; the comment above `.dc-scrim`.

## Accessible and calm

The track is one tab stop with the active card as the only other one (a roving tabindex), ← → Home End move it, Space plays or pauses, Enter opens the card. A polite live region says "Slide 3 of 9, The Perspective" 450 ms after the active slide changes, so a flick across five cards is announced once. Autoplay pauses on hover, under keyboard focus, while dragging, in a hidden tab and when less than half the carousel is in view, and stops for good once the viewer takes control. With reduced motion, snaps become short tweens, lean and parallax are off, autoplay waits for the play button and the detail view cross-fades instead of morphing. axe finds no violations, light or dark, with the detail view open.

## Make it yours

`slides` takes any mix of image, video, statement, text, stat and custom cards; `depth`, `tilt` and `parallax` set the feel (0.55, 0.6 and 0.5 are the tuned defaults), `cardWidth`, `gap` and `aspect` the size, `theme` and `accent` the colours, and `renderImage` lets you draw pictures with `next/image`. The demo's Tune panel (`?tune`) has live sliders for every value and copies the props it ends on.

```bash
npx shadcn@latest add @upfork/dynamic-3d-carousel-react
```

---

On UpFork: https://upfork.dev/articles/a-3d-react-carousel-that-never-re-renders-while-it-moves-physics-a-seamless
