# OrbitGallery: a React Three Fiber carousel, a glide that always lands, and nine WebGL traps

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

Behind the build, by [Ramin Valadan](https://upfork.dev/developers/ramin-valadan), about [OrbitGallery — 3D Carousel for React & Three.js (WebGL)](https://upfork.dev/products/orbit-gallery-3d-carousel-react) (Component, comes with the Pro plan). Published 2026-10-26. 9 min read. Written with AI: Claude Opus 5.5, through Claude.

## What it is

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

[Live preview of OrbitGallery — 3D Carousel for React & Three.js (WebGL)](https://upfork.dev/products/orbit-gallery-3d-carousel-react)

This 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).

## One store, two kinds of change

Everything 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:

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

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

## Physics: a fling that lands exactly on a card

The constants, in cards and seconds:

`components/orbit-gallery-store.ts`, lines 19–40:

```ts
export const PHYSICS = {
  /** Pixels a press must travel before it becomes a drag, so clicks still register. */
  DRAG_THRESHOLD_PX: 4,
  /** λ of the exponential decay of velocity, per second: a fling travels velocity / λ cards. */
  DAMPING: 4,
  /** Flings are capped at this many cards per second. */
  MAX_VELOCITY: 14,
  /** Velocity a wheel pixel adds, in cards per second: one 100 px notch then travels one card (100 × 0.04 / λ). */
  WHEEL_VELOCITY_PER_PIXEL: 0.04,
  /** Seconds without input after which a coast resolves into a snap. */
  INPUT_SETTLE: 0.09,
  /** Stiffness ω of the critically damped spring used from rest (keys, clicks, autoplay): within 1% in 0.6 s. */
  SPRING_OMEGA: 12,
  /**
   * A glide re-solves its decay rate to land on a card; this is the range it may pick from (per second). Below 3 the
   * tail creeps on for seconds; above 8 the stop feels like a brake.
   */
  GLIDE_RATE_MIN: 3,
  GLIDE_RATE_MAX: 8,
  /** Below this speed (cards per second) a release springs to the nearest card instead of gliding. */
  REST_SPEED: 0.35,
  /** How far past its ends a finite list follows the pointer: 0.35 of the distance dragged. */
```

A 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:

`components/orbit-gallery-store.ts`, lines 317–343:

```ts
  /**
   * Resolve a coast into a snap. Velocity decays exponentially (rate λ) and would come to rest at p + v/λ. Pick the
   * card nearest that rest point in the direction of travel, then re-solve the decay rate so the same exponential
   * ends exactly on it: λ' = v / (target − p). Velocity stays continuous and motion is monotonic, so it cannot
   * overshoot. When no card gives a sensible rate (too close, or too slow to bother) a spring does it instead.
   */
  function resolveSnap() {
    const p = motion.position;
    const v = motion.velocity;
    if (!config.snap) {
      motion.phase = "free";
      return;
    }
    if (Math.abs(v) < PHYSICS.REST_SPEED) {
      springTo(p);
      return;
    }
    const target = glideTarget(p, v);
    if (target === null) {
      springTo(p + v / PHYSICS.DAMPING);
      return;
    }
    motion.target = target;
    motion.glideRate = v / (target - p);
    motion.phase = "glide";
    if (config.reducedMotion) settle();
  }
```

If 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:

`components/orbit-gallery-store.ts`, lines 345–364:

```ts
  function glideTarget(p: number, v: number): number | null {
    const dir = Math.sign(v);
    const rest = p + v / PHYSICS.DAMPING;
    const candidates = [Math.floor(rest) - dir, Math.floor(rest), Math.ceil(rest), Math.ceil(rest) + dir];
    let best: number | null = null;
    let bestScore = Infinity;
    for (const raw of candidates) {
      const t = bound(raw);
      const distance = (t - p) * dir;
      if (distance <= PHYSICS.SETTLE_DISTANCE) continue;
      const rate = v / (t - p);
      if (rate < PHYSICS.GLIDE_RATE_MIN || rate > PHYSICS.GLIDE_RATE_MAX) continue;
      const score = Math.abs(Math.log(rate / PHYSICS.DAMPING));
      if (score < bestScore) {
        bestScore = score;
        best = t;
      }
    }
    return best;
  }
```

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

## One shader for every card

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

`components/orbit-gallery-card-material.ts`, lines 128–134:

```ts
  // 1 px hairline just inside the edge, then the focus ring (2 px, a pixel further in). The hairline is mixed in
  // perceptual (gamma) space, like CSS would, so its 12 % reads as 12 % and not as a bright line.
  float inside = -d / px;
  float hairline = 1.0 - smoothstep(1.0, 1.8, inside);
  vec3 g = pow(max(color, 0.0), vec3(1.0 / 2.2));
  g = mix(g, pow(uBorderColor, vec3(1.0 / 2.2)), hairline * uBorderAlpha);
  color = pow(g, vec3(2.2));
```

The floor's grid has the same problem, solved the same way:

`components/orbit-gallery-floor.tsx`, lines 84–91:

```tsx
  // Intro: drawn in from the far edge toward the viewer.
  float reveal = smoothstep(uFadeEnd * (1.0 - uReveal) - 2.0, uFadeEnd * (1.0 - uReveal) + 2.0, dist);
  float alpha = line * detail * radial * reveal * uAlpha * (1.0 - uOpen);
  // Blending happens in linear light, where 8 % white over black would read as about 30 % on screen. Light lines on
  // a dark floor get their alpha in perceptual terms instead, so the token's 8 % looks like 8 %.
  alpha = mix(alpha, pow(alpha, 2.2) * 3.0, uPerceptual);
  if (alpha < 0.0005) discard;
  gl_FragColor = vec4(uColor, alpha);
```

> **Lesson: Alpha blending in linear light makes an 8% white line on black look like 30%: mix thin lines in perceptual space** (My own mistake; learned by the AI (Claude Opus 5.5); three@0.182)
> **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.
> **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.
> **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%.
> **Evidence:** commit 11cf14a (the floor and its grid); the comments above both blocks.

`components/orbit-gallery-card-material.ts`, lines 111–117:

```ts
  if (uMix > 0.001) {
    vec3 next = picture(uMap2, uv, uMap2Scale);
    // three.js uploads video frames without sRGB decoding (its own materials decode them in the shader), so a video
    // in uMap2 is decoded here; pictures arrive already linear.
    if (uMap2Video > 0.5) next = sRGBTransferEOTF(vec4(next, 1.0)).rgb;
    color = mix(color, next, uMix);
  }
```

> **Lesson: three.js uploads video frames without sRGB decoding for a custom ShaderMaterial: decode them in the shader** (Not in the docs; learned by the AI (Claude Opus 5.5); three@0.182)
> **I assumed:** a video texture arrives in a custom shader already decoded to linear, the same as an image texture does.
> **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.
> **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.
> **Evidence:** commit 0f6977b ("Video cards decode sRGB in the shader (three uploads video frames undecoded for custom materials)").

## The floor: a reflector, tuned per theme

The 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:

`components/orbit-gallery-floor.tsx`, lines 160–178:

```tsx
        <mesh rotation-x={-Math.PI / 2} renderOrder={-2}>
          <planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
          <MeshReflectorMaterial
            resolution={resolution}
            blur={[400, 100]}
            mixBlur={1}
            mixStrength={dark ? REFLECTION_DARK : REFLECTION_LIGHT}
            mixContrast={1}
            roughness={0.9}
            metalness={dark ? 0.5 : 0}
            mirror={0.4}
            // Depth-based blur sharpens the reflection where a card meets the floor, but it leaves a fringe where a
            // card meets the background: invisible on black, a bright jagged outline on paper.
            depthScale={dark ? 1.1 : 0}
            minDepthThreshold={0.35}
            maxDepthThreshold={1.25}
            color={floorColor}
          />
        </mesh>
```

> **Lesson: drei's MeshReflectorMaterial multiplies the reflection by the floor's colour: on a near-black floor it vanishes** (Not in the docs; learned by the AI (Claude Opus 5.5); @react-three/drei@10)
> **I assumed:** a `mixStrength` around 1.2, the value the brief gave, would show a visible reflection on the dark stage.
> **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.
> **The fix:** `mixStrength` is 60 on the dark stage and 1.4 on the light one, with `metalness` 0.5 on dark only.
> **Evidence:** the build plan's table of departures from the brief; `REFLECTION_DARK` and `REFLECTION_LIGHT` in orbit-gallery-floor.tsx.

> **Lesson: MeshReflectorMaterial's depth blur leaves a bright jagged fringe on a light background: turn it off there** (The tools and the machine; learned by the AI (Claude Opus 5.5); @react-three/drei@10)
> **I assumed:** the depth-based blur (`depthScale`), which sharpens the reflection where a card meets the floor, suited both themes.
> **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.
> **The fix:** `depthScale` is 1.1 on the dark stage and 0 on the light one.
> **Evidence:** commit 8fed05e ("The light floor drops its depth blur, which fringed the reflections").

## Getting to the first frame without blocking the page

The brief asked for drei's `<Environment preset="city">` for reflections. It isn't there:

`components/orbit-gallery-scene.tsx`, lines 50–52:

```tsx
// No environment map: drei's <Environment> (the brief's "city" preset at 0.25, for reflections only) costs a 3.3 s
// first frame on Windows' D3D backend (PMREM pre-filtering) and downloads its HDR from a CDN. The floor's
// reflections come from the reflector itself; the hemisphere and key lights do the rest.
```

> **Lesson: drei's Environment cost a 3.3 s first frame on Windows (PMREM pre-filtering) and fetches its HDR from a CDN** (The tools and the machine; learned by the AI (Claude Opus 5.5); @react-three/drei@10)
> **I assumed:** an environment preset at low intensity, used only for reflections, would be cheap.
> **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).
> **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.
> **Evidence:** commit 11cf14a; the comment in orbit-gallery-scene.tsx and the build plan's departures table.

Then the shaders. The canvas starts with its frame loop off:

`components/orbit-gallery-scene.tsx`, lines 75–85:

```tsx
    <Canvas
      className="og-canvas"
      flat
      // Nothing is drawn until every shader has compiled (see SceneContents): the first frame then costs no compile.
      frameloop="never"
      dpr={[1, TIER_DPR[tier]!]}
      gl={{ antialias: false, alpha: false, stencil: false, powerPreference: "high-performance" }}
      camera={{ fov: FOV, near: 0.1, far: 120, position: [0, 0.42, 5.2] }}
      onCreated={({ gl }) => gl.setClearColor(background)}
      aria-hidden
    >
```

and turns it on only once every material has compiled:

`components/orbit-gallery-scene.tsx`, lines 119–135:

```tsx
  // Compile every material in parallel, off the main thread where the browser can (KHR_parallel_shader_compile),
  // then start the frame loop. Compiling inside the first frame instead blocked the page for 100–400 ms.
  useEffect(() => {
    let live = true;
    const t0 = performance.now();
    const start = () => {
      if (!live) return;
      debug("compiled in", Math.round(performance.now() - t0), "ms");
      setFrameloop("always");
    };
    const compile = (gl as typeof gl & { compileAsync?: (s: typeof scene, c: typeof camera) => Promise<unknown> }).compileAsync;
    if (compile) compile.call(gl, scene, camera).then(start, start);
    else start();
    return () => {
      live = false;
    };
  }, [gl, scene, camera, setFrameloop]);
```

> **Lesson: Compiling three.js shaders inside the first frame blocks the page: compile with compileAsync while the frame loop waits** (The tools and the machine; learned by the AI (Claude Opus 5.5); three@0.182, @react-three/fiber@9)
> **I assumed:** letting three.js compile the shaders lazily on the first frame was fine for a component that loads after the page.
> **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.
> **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.
> **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").

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

> **Lesson: A post-processing depth-of-field pass ran at 20 fps on an integrated GPU: defocus in the card shader instead** (The tools and the machine; learned by the AI (Claude Opus 5.5); @react-three/postprocessing@3)
> **I assumed:** the cinematic preset could use the post-processing `DepthOfField` pass for its defocus.
> **What happened:** at a device pixel ratio of 2 the bokeh pass held the scene at about 20 fps on an Intel Iris Plus.
> **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.
> **Evidence:** commit 11cf14a ("the real bokeh pass is kept for quality=high (it ran at 20 fps on an integrated GPU)").

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

`components/orbit-gallery-scene.tsx`, lines 176–185:

```tsx
  useFrame((_, dt) => {
    store.step(dt);
    cache.uploadPending();
    shared.focusVisible = focusVisible.current;
  }, -2);

  // After the cards have moved this frame (same priority, registered after them: a parent subscribes after its
  // children): hover, the key light, the accent. No positive priorities anywhere: those would take rendering over
  // from R3F and from the effect composer.
  useFrame((_, dt) => {
```

## Shipping it as a package and as a registry

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

> **Lesson: tsup's rollup tree-shaking pass strips the use client banner from its output: turn it off and let esbuild shake** (Not in the docs; learned by the AI (Claude Opus 5.5); tsup@8.5)
> **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.
> **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.
> **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.
> **Evidence:** commit bc05c54 (tsup.config.ts) and commit 8fed05e (the Next.js 16 test app).

```ts
// esbuild tree-shakes on its own; tsup's extra rollup pass would strip the "use client" banner below.
treeshake: false,
splitting: true,
target: "es2020",
// Every file of the package is client code; Next.js needs the directive at the top of each output chunk.
banner: { js: '"use client";' },
```

> **Lesson: three r183 and later make React Three Fiber 9 print a THREE.Clock warning: pin the dev builds to 0.182** (Newer than what I knew; learned by the AI (Claude Opus 5.5); three@0.183, @react-three/fiber@9)
> **I assumed:** the latest three.js would run under React Three Fiber 9 with a silent console, which the brief required.
> **What happened:** with three r183 and later, React Three Fiber 9 printed a `THREE.Clock` warning in the console.
> **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.
> **Evidence:** commit 8fed05e; the build plan's departures table.

## Accessible, indexable, light on memory

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

## Make it yours

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

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

---

On UpFork: https://upfork.dev/articles/orbitgallery-a-react-three-fiber-carousel-a-glide-that-always-lands-and-nine
