{
  "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-96",
  "entry": {
    "seq": 96,
    "kind": "article-version",
    "record": "{\"ai\":{\"apps\":[\"Claude\"],\"models\":[\"Claude Opus 5.5\"]},\"article\":\"orbitgallery-a-react-three-fiber-carousel-a-glide-that-always-lands-and-nine\",\"author\":{\"account\":\"a_9d470b566acab5f65bdc\",\"handle\":\"ramin-valadan\"},\"body\":\"## What it is\\n\\nOrbitGallery is a 3D carousel for React: project cards bent along a curved arc above a reflective floor, built with React Three Fiber. Drag, scroll or use the arrow keys, and a fling always comes to rest exactly on a card; click one and it flies to the camera and opens into a case-study view. It has arc, grid and stack layouts that morph into each other, light, dark and auto themes, and a server-rendered list of every project for search engines and screen readers.\\n\\n::preview\\n\\nThis article explains how it is built, for anyone (person or AI) making a WebGL component in React: one headless store that owns all the physics, the maths of a glide that can't overshoot, one shader for every card, how the scene gets to its first frame without blocking the page, and nine traps in three.js, drei and the build that we met on the way. Every number was measured in Chrome on a laptop with an Intel Iris Plus (an integrated GPU).\\n\\n## One store, two kinds of change\\n\\nEverything that moves is owned by one plain TypeScript object, `createOrbitStore`, with no React and no three.js in it, which is what the unit tests drive. It keeps two kinds of change apart:\\n\\n- **Discrete state** (the active index, the mode, the filter, the layout) changes rarely. React reads it through `useSyncExternalStore`, so only the DOM layer (title, index, cursor, detail view, the accessible list) re-renders, and only when it actually changes.\\n- **Motion** (position, velocity, the open transition, the intro) changes every frame. It lives in a mutable object the 3D scene reads in its frame loop, so dragging never re-renders React.\\n\\nThe scene calls `store.step(dt)` once per frame and only reads; input (pointer, wheel, touch, keys) writes to the store through a small hook. Layouts are pure functions from a card's index to its position, rotation, scale and bend, and a morph between two layouts is a per-card lerp between both results evaluated every frame against the live position, so a morph stays right while the carousel moves. The WebGL scene is code-split and loaded only after mount, and only where WebGL exists; without it, a CSS scroll-snap strip takes its place.\\n\\n## Physics: a fling that lands exactly on a card\\n\\nThe constants, in cards and seconds:\\n\\n::file{path=\\\"components/orbit-gallery-store.ts\\\" lines=\\\"19-40\\\"}\\n\\nA drag follows the pointer 1:1. On release, velocity decays exponentially: v(t) = v₀·e^(−λt) with λ = 4 per second, so a fling would travel v₀/λ cards and come to rest at p + v₀/λ. That rest point is almost never on a card. The usual fix, a spring to the nearest card, overshoots and wobbles back. OrbitGallery keeps the exponential instead and changes its rate:\\n\\n::file{path=\\\"components/orbit-gallery-store.ts\\\" lines=\\\"317-343\\\"}\\n\\nIf the glide should end exactly on card t, its rate must satisfy t − p = v/λ′, so λ′ = v / (t − p). Velocity stays continuous at the moment of release (the glide starts at the hand's speed), and an exponential decay is monotonic, so it cannot pass the card or come back. Which card? The candidates around the natural rest point, keeping only those whose rate stays between 3 and 8 per second (below 3 the tail creeps on for seconds, above 8 it stops like a brake), and preferring the rate closest to the natural one:\\n\\n::file{path=\\\"components/orbit-gallery-store.ts\\\" lines=\\\"345-364\\\"}\\n\\nFrom rest (keys, clicks, autoplay) a critically damped spring with ω = 12 takes over, within 1% of the target in 0.6 s. Both motions are solved exactly over each step rather than integrated, so they don't depend on the frame rate, and a unit test throws 300 random flings and checks that each lands on a card, at most one turn on, and never passes it.\\n\\n## One shader for every card\\n\\nAll cards share one shader program with per-card uniforms: the card is bent along the arc in the vertex shader (with a wind ripple that grows with speed), its rounded corners are a signed distance field with a 1 px hairline, and the picture is cover-fitted with a hover parallax, an RGB split with speed, a light sweep on hover and a blur-up from a 24 px placeholder to the poster to the active card's video. Two colour-space traps hid in there.\\n\\n::file{path=\\\"components/orbit-gallery-card-material.ts\\\" lines=\\\"128-134\\\"}\\n\\nThe floor's grid has the same problem, solved the same way:\\n\\n::file{path=\\\"components/orbit-gallery-floor.tsx\\\" lines=\\\"84-91\\\"}\\n\\n:::lesson{title=\\\"Alpha blending in linear light makes an 8% white line on black look like 30%: mix thin lines in perceptual space\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\" packages=\\\"three@0.182\\\"}\\n**I assumed:** a floor grid drawn with the theme's 8% white, and a card hairline at 12%, would look like 8% and 12%, as they would in CSS.\\n**What happened:** three.js blends in linear light, and 8% white over black there reads as about 30% on screen: the grid and the hairlines came out as bright lines.\\n**The fix:** light lines on a dark floor get their alpha in perceptual terms, `mix(alpha, pow(alpha, 2.2) * 3.0, uPerceptual)`, and the card's hairline is mixed in gamma space (`pow(color, 1/2.2)`, mix, back with `pow(…, 2.2)`), like CSS, so 12% reads as 12%.\\n**Evidence:** commit 11cf14a (the floor and its grid); the comments above both blocks.\\n:::\\n\\n::file{path=\\\"components/orbit-gallery-card-material.ts\\\" lines=\\\"111-117\\\"}\\n\\n:::lesson{title=\\\"three.js uploads video frames without sRGB decoding for a custom ShaderMaterial: decode them in the shader\\\" kind=\\\"not-in-docs\\\" by=\\\"ai\\\" packages=\\\"three@0.182\\\"}\\n**I assumed:** a video texture arrives in a custom shader already decoded to linear, the same as an image texture does.\\n**What happened:** three.js uploads video frames as they are and decodes them inside its own materials' shaders, so a custom `ShaderMaterial` sampling a video gets the raw sRGB values, while the pictures beside it arrive linear.\\n**The fix:** the card shader decodes the video sampler itself with three's `sRGBTransferEOTF` (`if (uMap2Video > 0.5) next = sRGBTransferEOTF(vec4(next, 1.0)).rgb;`); pictures are left alone.\\n**Evidence:** commit 0f6977b (\\\"Video cards decode sRGB in the shader (three uploads video frames undecoded for custom materials)\\\").\\n:::\\n\\n## The floor: a reflector, tuned per theme\\n\\nThe floor is drei's `MeshReflectorMaterial` with a shader grid on top that turns with the arc and draws in from the horizon during the intro:\\n\\n::file{path=\\\"components/orbit-gallery-floor.tsx\\\" lines=\\\"160-178\\\"}\\n\\n:::lesson{title=\\\"drei's MeshReflectorMaterial multiplies the reflection by the floor's colour: on a near-black floor it vanishes\\\" kind=\\\"not-in-docs\\\" by=\\\"ai\\\" packages=\\\"@react-three/drei@10\\\"}\\n**I assumed:** a `mixStrength` around 1.2, the value the brief gave, would show a visible reflection on the dark stage.\\n**What happened:** the reflection is multiplied by the floor's own colour, and the dark stage's floor is near black, so at 1.2 nothing showed.\\n**The fix:** `mixStrength` is 60 on the dark stage and 1.4 on the light one, with `metalness` 0.5 on dark only.\\n**Evidence:** the build plan's table of departures from the brief; `REFLECTION_DARK` and `REFLECTION_LIGHT` in orbit-gallery-floor.tsx.\\n:::\\n\\n:::lesson{title=\\\"MeshReflectorMaterial's depth blur leaves a bright jagged fringe on a light background: turn it off there\\\" kind=\\\"tools\\\" by=\\\"ai\\\" packages=\\\"@react-three/drei@10\\\"}\\n**I assumed:** the depth-based blur (`depthScale`), which sharpens the reflection where a card meets the floor, suited both themes.\\n**What happened:** it also leaves a fringe where a card meets the background: invisible on black, a bright jagged outline on the light theme's paper.\\n**The fix:** `depthScale` is 1.1 on the dark stage and 0 on the light one.\\n**Evidence:** commit 8fed05e (\\\"The light floor drops its depth blur, which fringed the reflections\\\").\\n:::\\n\\n## Getting to the first frame without blocking the page\\n\\nThe brief asked for drei's `<Environment preset=\\\"city\\\">` for reflections. It isn't there:\\n\\n::file{path=\\\"components/orbit-gallery-scene.tsx\\\" lines=\\\"50-52\\\"}\\n\\n:::lesson{title=\\\"drei's Environment cost a 3.3 s first frame on Windows (PMREM pre-filtering) and fetches its HDR from a CDN\\\" kind=\\\"tools\\\" by=\\\"ai\\\" packages=\\\"@react-three/drei@10\\\"}\\n**I assumed:** an environment preset at low intensity, used only for reflections, would be cheap.\\n**What happened:** on Windows, where WebGL runs on Direct3D, its PMREM pre-filtering made the first frame take 3.3 s on an Intel Iris Plus; and the preset downloads its HDR file from a CDN at run time, which a component that is sold must not depend on (that host is also unreachable on some networks).\\n**The fix:** no environment map: the floor's reflections come from the reflector itself, and a hemisphere light plus a key light that follows the active card do the rest.\\n**Evidence:** commit 11cf14a; the comment in orbit-gallery-scene.tsx and the build plan's departures table.\\n:::\\n\\nThen the shaders. The canvas starts with its frame loop off:\\n\\n::file{path=\\\"components/orbit-gallery-scene.tsx\\\" lines=\\\"75-85\\\"}\\n\\nand turns it on only once every material has compiled:\\n\\n::file{path=\\\"components/orbit-gallery-scene.tsx\\\" lines=\\\"119-135\\\"}\\n\\n:::lesson{title=\\\"Compiling three.js shaders inside the first frame blocks the page: compile with compileAsync while the frame loop waits\\\" kind=\\\"tools\\\" by=\\\"ai\\\" packages=\\\"three@0.182, @react-three/fiber@9\\\"}\\n**I assumed:** letting three.js compile the shaders lazily on the first frame was fine for a component that loads after the page.\\n**What happened:** compiling inside the first frame blocked the main thread for 100–400 ms, and the demo's total blocking time was 700 ms.\\n**The fix:** the `<Canvas>` starts with `frameloop=\\\"never\\\"`; `renderer.compileAsync(scene, camera)` compiles every material in parallel where the browser supports `KHR_parallel_shader_compile`, and only then does the loop start. The quality governor also waits for the intro before it judges the frame rate. Total blocking time went from 700 to 240 ms, and Lighthouse on desktop reads 91–92 / 100 / 100 / 100.\\n**Evidence:** commit 8fed05e (\\\"shaders compile in parallel before the first frame (compileAsync) and the quality governor waits for the intro, which took the demo's blocking time from 700 to 240 ms\\\").\\n:::\\n\\nPost-processing comes in presets: `balanced` (bloom on HDR glows only, vignette, grain, aberration with speed) and `cinematic`, which also defocuses the cards away from the front.\\n\\n:::lesson{title=\\\"A post-processing depth-of-field pass ran at 20 fps on an integrated GPU: defocus in the card shader instead\\\" kind=\\\"tools\\\" by=\\\"ai\\\" packages=\\\"@react-three/postprocessing@3\\\"}\\n**I assumed:** the cinematic preset could use the post-processing `DepthOfField` pass for its defocus.\\n**What happened:** at a device pixel ratio of 2 the bokeh pass held the scene at about 20 fps on an Intel Iris Plus.\\n**The fix:** the cinematic look defocuses cards in their own shader with a mip-bias blur, which looks close and costs nothing; the real pass is kept for `quality=\\\"high\\\"`. A performance monitor steps quality down when frames run long.\\n**Evidence:** commit 11cf14a (\\\"the real bokeh pass is kept for quality=high (it ran at 20 fps on an integrated GPU)\\\").\\n:::\\n\\nThe frame loop itself has an order: the store steps first, at priority −2, then the cards move, then a callback registered after them reads where the active card ended up and moves the key light. No callback uses a positive priority, since in React Three Fiber that takes rendering over from the library and from the effect composer.\\n\\n::file{path=\\\"components/orbit-gallery-scene.tsx\\\" lines=\\\"176-185\\\"}\\n\\n## Shipping it as a package and as a registry\\n\\nOrbitGallery ships two ways: an npm-style package built with tsup (ESM, CJS and types) and, on UpFork, 19 flat files the shadcn CLI installs. Two traps came from the tooling around three.js rather than from three.js itself.\\n\\n:::lesson{title=\\\"tsup's rollup tree-shaking pass strips the use client banner from its output: turn it off and let esbuild shake\\\" kind=\\\"not-in-docs\\\" by=\\\"ai\\\" packages=\\\"tsup@8.5\\\"}\\n**I assumed:** `banner: { js: '\\\"use client\\\";' }` in tsup's config would put the directive at the top of every output chunk, so the package works in the Next.js App Router.\\n**What happened:** tsup's extra rollup pass for `treeshake: true` dropped the banner from the output, and without the directive the App Router can't tell the chunks are client code.\\n**The fix:** `treeshake: false`, since esbuild tree-shakes on its own, and the banner stays on every chunk. A Next.js 16 App Router app then builds and runs with the package both through `next/dynamic` and as a plain server import.\\n**Evidence:** commit bc05c54 (tsup.config.ts) and commit 8fed05e (the Next.js 16 test app).\\n:::\\n\\n```ts\\n// esbuild tree-shakes on its own; tsup's extra rollup pass would strip the \\\"use client\\\" banner below.\\ntreeshake: false,\\nsplitting: true,\\ntarget: \\\"es2020\\\",\\n// Every file of the package is client code; Next.js needs the directive at the top of each output chunk.\\nbanner: { js: '\\\"use client\\\";' },\\n```\\n\\n:::lesson{title=\\\"three r183 and later make React Three Fiber 9 print a THREE.Clock warning: pin the dev builds to 0.182\\\" kind=\\\"newer\\\" by=\\\"ai\\\" packages=\\\"three@0.183, @react-three/fiber@9\\\"}\\n**I assumed:** the latest three.js would run under React Three Fiber 9 with a silent console, which the brief required.\\n**What happened:** with three r183 and later, React Three Fiber 9 printed a `THREE.Clock` warning in the console.\\n**The fix:** the demo and dev builds pin three to 0.182, and the console stays empty; the package's peer range stays `>=0.170`, so buyers choose.\\n**Evidence:** commit 8fed05e; the build plan's departures table.\\n:::\\n\\n## Accessible, indexable, light on memory\\n\\nNext to the canvas, a server-rendered list holds every project as real HTML, so search engines index it and screen readers read it. Tab lands on the active project and the arrows move both the list and the carousel (a roving focus), a polite live region announces changes, the focus ring is drawn on the active card itself, and reduced motion is honoured throughout. The detail view is a modal dialog with focus trapped and given back. Twenty open-and-close cycles leave the renderer flat (6 geometries, 36 textures) and the heap at 9 to 10 MB.\\n\\n## Make it yours\\n\\n`items` takes your projects (`title`, `subtitle`, `description`, `image`, an optional `video`, `href`, `tags`, `accent`, `placeholder`); `layout` is `arc`, `grid` or `stack`; `effects` is `off`, `balanced` or `cinematic`; `floor` is `reflective`, `grid` or `none`; `quality` is `auto`, `high` or `low`; `theme` is light, dark, auto or tokens of your own; `brand`, `nav` and `cta` fill the corners; and `renderDetail` replaces the case-study view with your own. The demo's leva panel tunes every value and copies the props.\\n\\n::install\",\"bodySha256\":\"3a1fd542ae19cf81c6d9f8de10eb01d05e8df71b13e1744703106b05ed4bcab9\",\"kind\":\"behind-the-build\",\"product\":\"orbit-gallery-3d-carousel-react\",\"productRevision\":4,\"publishedAt\":\"2026-10-26T09:30:43.802Z\",\"revision\":1,\"site\":\"https://upfork.dev\",\"summary\":\"How OrbitGallery's headless store makes every fling land exactly on a card without React re-rendering, and the colour-space, drei and build traps we hit with three.js.\",\"title\":\"OrbitGallery: a React Three Fiber carousel, a glide that always lands, and nine WebGL traps\",\"type\":\"article-version\",\"url\":\"https://upfork.dev/articles/orbitgallery-a-react-three-fiber-carousel-a-glide-that-always-lands-and-nine\"}",
    "recordHash": "122837e8cdbddee256d199f2ebe88b64d344e3a891bbd2aadc16212c28067a30",
    "prevHash": "77d6c01f82ce4b43a1fef3dd0e93aad92d4d104e11dc22678322e2dd7b4afe3a",
    "entryHash": "30f42d1a72502486dc43a5f94c2e8c62dbd26532f74fa9f76c59d288df625653",
    "keyId": "k1",
    "signature": "6RK1u0YDF8W6JKEhKT+JGKYIgARLmD9fJ8Vwpz8BTSfimntXZuKVhB72BT5Kcl5PAqWMBPdCsSQj4QhIOm1jAg==",
    "recordedAt": "2026-10-26T09:31:08.786949+00:00",
    "publicKey": "VuHuHXAtO3XAqgnwvMenp0f/3vTV061hnAIizYvwh40=",
    "algorithm": "Ed25519"
  },
  "path": [],
  "checkpoint": null,
  "keys": "https://upfork.dev/ledger/keys.json"
}