{
  "about": "An entry of UpFork's ledger. Check it by hand as https://upfork.dev/docs/ledger says, or at https://upfork.dev/verify/E-79",
  "entry": {
    "seq": 79,
    "kind": "article-version",
    "record": "{\"ai\":{\"apps\":[\"Claude\"],\"models\":[\"Claude Opus 5.5\"]},\"article\":\"a-3d-react-carousel-that-never-re-renders-while-it-moves-physics-a-seamless\",\"author\":{\"account\":\"a_9d470b566acab5f65bdc\",\"handle\":\"ramin-valadan\"},\"body\":\"## What it is\\n\\nDynamic 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.\\n\\n::preview\\n\\nThis 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.\\n\\n## One number drives everything\\n\\nThe 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.\\n\\nThe resting pose on the arc, for a distance d and an arc strength `depth`:\\n\\n::file{path=\\\"components/dynamic-carousel/engine/pose.ts\\\" lines=\\\"47-59\\\"}\\n\\nAt 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.\\n\\nOn top of the pose, cards lean into motion. The lean comes from the track's velocity, passed through a spring of its own:\\n\\n::file{path=\\\"components/dynamic-carousel/engine/useCarouselEngine.ts\\\" lines=\\\"661-667\\\"}\\n\\nThat 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.\\n\\n## A loop with no seam and no clones\\n\\nThe 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:\\n\\n::file{path=\\\"components/dynamic-carousel/engine/loop.ts\\\" lines=\\\"10-19\\\"}\\n\\nA 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.\\n\\n## Throwing: velocity, projection, snap\\n\\nWhen the hand lets go, three small pure functions decide where the cards come to rest.\\n\\n**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.\\n\\n**Projection.** How far a flick would carry on its own:\\n\\n::file{path=\\\"components/dynamic-carousel/engine/velocity.ts\\\" lines=\\\"36-43\\\"}\\n\\nA 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.\\n\\n**Snap.** The landing point is rounded to the nearest card, with two rules:\\n\\n::file{path=\\\"components/dynamic-carousel/engine/snap.ts\\\" lines=\\\"16-34\\\"}\\n\\nNo 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.\\n\\nThen 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:\\n\\n::file{path=\\\"components/dynamic-carousel/engine/useCarouselEngine.ts\\\" lines=\\\"147-164\\\"}\\n\\nThe 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.\\n\\n## Taps, drags and pointer capture\\n\\nA 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:\\n\\n::file{path=\\\"components/dynamic-carousel/engine/useCarouselEngine.ts\\\" lines=\\\"436-459\\\"}\\n\\n:::lesson{title=\\\"Pointer capture sends the click to the capturing element: resolve taps from the card under the pointer at pointerdown\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**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.\\n**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.\\n**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.\\n**Evidence:** commit 98e4f3c; `Gesture.slot` and `release` in useCarouselEngine.ts.\\n:::\\n\\n## The wheel: never trap the page\\n\\nHorizontal 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:\\n\\n::file{path=\\\"components/dynamic-carousel/engine/useCarouselEngine.ts\\\" lines=\\\"495-503\\\"}\\n\\nA 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.\\n\\n## Zero React renders while it moves\\n\\nEverything 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.\\n\\nComputed 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:\\n\\n::file{path=\\\"components/dynamic-carousel/CarouselSlideCard.tsx\\\" lines=\\\"382-396\\\"}\\n\\n:::lesson{title=\\\"motion's function-form useTransform subscribes only to the values its first run reads: an early return froze the cards\\\" kind=\\\"not-in-docs\\\" by=\\\"ai\\\" packages=\\\"motion@14\\\"}\\n**I assumed:** `useTransform(() => …)` recomputes whenever any motion value it reads changes, like a computed signal that collects its inputs on every run.\\n**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.\\n**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.\\n**Evidence:** commit 98e4f3c; the test \\\"moves the cards themselves, in a render that never repeats (a client-only app, UpFork's preview)\\\".\\n:::\\n\\n## The first paint, before any script\\n\\nThe 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:\\n\\n::file{path=\\\"components/dynamic-carousel/engine/pose.ts\\\" lines=\\\"77-88\\\"}\\n\\nThe 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.\\n\\nThe pictures nearest the anchor load eagerly with a high fetch priority. How they appear mattered as much as when they load:\\n\\n::file{path=\\\"components/dynamic-carousel/CarouselSlideCard.tsx\\\" lines=\\\"79-86\\\"}\\n\\n:::lesson{title=\\\"A fade-in started from onLoad waits for hydration and delays LCP: render first-screen pictures already marked loaded\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** every picture could start at opacity 0 and fade in from its `onLoad` handler, the first ones included.\\n**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.\\n**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.\\n**Evidence:** commit 98e4f3c; `Picture` in CarouselSlideCard.tsx.\\n:::\\n\\n:::lesson{title=\\\"A picture that fails to load shows the browser's broken-image alt text over the card: hide the img, keep the plate\\\" kind=\\\"tools\\\" by=\\\"ai\\\"}\\n**I assumed:** a picture that failed to load would simply leave its blurred placeholder showing.\\n**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.\\n**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.\\n**Evidence:** commit 98e4f3c; styles.css line 218.\\n:::\\n\\n## The detail view: a morph with no jump\\n\\nClicking 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:\\n\\n::file{path=\\\"components/dynamic-carousel/styles.css\\\" lines=\\\"145-148\\\"}\\n\\n`--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:\\n\\n::file{path=\\\"components/dynamic-carousel/CarouselDetail.tsx\\\" lines=\\\"18-26\\\"}\\n\\n:::lesson{title=\\\"Measure a FLIP morph's start with transform none, or StrictMode's second run measures an element already moved\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\" packages=\\\"react@19.2\\\"}\\n**I assumed:** the card's `getBoundingClientRect()` when the view opened was where the morph should start.\\n**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.\\n**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.\\n**Evidence:** commit 98e4f3c; `homeRect` in CarouselDetail.tsx.\\n:::\\n\\nInside 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.\\n\\n## Captions you can read on every photo\\n\\nTitles and captions rise over the active picture on a gradient scrim:\\n\\n::file{path=\\\"components/dynamic-carousel/styles.css\\\" lines=\\\"226-236\\\"}\\n\\n:::lesson{title=\\\"A caption scrim tuned on typical photos fails on white architecture: measure contrast on the brightest picture\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** a scrim that kept white captions readable on the first demo photos would hold for the rest.\\n**What happened:** on photos of white architecture the captions fell below 4.5:1.\\n**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.\\n**Evidence:** commit 98e4f3c; the comment above `.dc-scrim`.\\n:::\\n\\n## Accessible and calm\\n\\nThe 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.\\n\\n## Make it yours\\n\\n`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.\\n\\n::install\",\"bodySha256\":\"2b1059c15453a957940847688e5ec9ed815d762196d010d03bc1fde364ae3b39\",\"kind\":\"behind-the-build\",\"product\":\"dynamic-3d-carousel-react\",\"productRevision\":1,\"publishedAt\":\"2026-10-26T08:46:39.806Z\",\"revision\":1,\"site\":\"https://upfork.dev\",\"summary\":\"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.\",\"title\":\"A 3D React carousel that never re-renders while it moves: physics, a seamless loop and lessons\",\"type\":\"article-version\",\"url\":\"https://upfork.dev/articles/a-3d-react-carousel-that-never-re-renders-while-it-moves-physics-a-seamless\"}",
    "recordHash": "79b94ee5dcb5eb532b0cb9ba73d51a424fef6633909a0e78f44cf2a1603a289f",
    "prevHash": "c97cfbbf931d1be5998e3c38dd6f3bde1bb2ecb978e858e4590467523ad7ccab",
    "entryHash": "18089746f1d151f6224a00339a496be79f1f31675dba8a76b6af1dd847d25fc3",
    "keyId": "k1",
    "signature": "vJpQhFD/LfuK0+2VJFh/mTwBoKyzrWw25QCCCY0e7s5qETxbjlV9q8KnGWUzh+r7s6j/kHPbtaeBufDcF15qBg==",
    "recordedAt": "2026-10-26T08:47:08.780121+00:00",
    "publicKey": "VuHuHXAtO3XAqgnwvMenp0f/3vTV061hnAIizYvwh40=",
    "algorithm": "Ed25519"
  },
  "path": [],
  "checkpoint": null,
  "keys": "https://upfork.dev/ledger/keys.json"
}