Skip to content
Kinetic
TransitionWebGL4 files

Canvas Handoff Transition

The usual way to put a shader on more than one page is to mount it on each of them, and the price is a new context, a new compile and a new first frame every time the route changes. No easing hides that: a field restarting from t = 0 is visibly a different object. Here one canvas is created imperatively, outside React's ownership, and moved into the destination page with `appendChild` during the layout phase — no paint happens in between, so the context never blinks. What the viewer sees is the surface they were already looking at, travelling from one rectangle to another.

webglthreerouteflipgsapcontextstrict-mode

Live preview

The real component, running in this page.

02

Install

Copies the source into your project. Nothing is imported from us at runtime.

$npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/canvas-handoff-transition.json
pulls inthree@^0.185.1gsap@^3.15.0

Also installs webgl, use-reduced-motion from this registry.

03

Prompt

The whole technique, written for an agent.

Build it with your agent

A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.

Claude CodeCursorCodexCopilotGemini CLI
.md
# Build: Canvas Handoff Transition

The usual way to put a shader on more than one page is to mount it on each of them, and the price is a new context, a new compile and a new first frame every time the route changes. No easing hides that: a field restarting from t = 0 is visibly a different object. Here one canvas is created imperatively, outside React's ownership, and moved into the destination page with `appendChild` during the layout phase — no paint happens in between, so the context never blinks. What the viewer sees is the surface they were already looking at, travelling from one rectangle to another.

Build this as a React component in my project. Match my existing conventions —
file layout, styling system, and naming — rather than the ones implied below.
Everything after this line is specification, not code to copy verbatim.

## What it does

The live WebGL canvas is handed to the next page and flies to its new slot, instead of being destroyed and rebuilt.

- Category: transition
- Runtime cost: webgl
- Dependencies: three@^0.185.1, gsap@^3.15.0

## How it works

1. Four steps in a fixed order. MEASURE first, inside the click handler, because one `setState` later the outgoing rectangle no longer exists. FLUSH one frame, so the pixels about to travel are current. MOVE in a `useLayoutEffect`, which shares its task with React's DOM mutations and therefore straddles no paint. RESIZE last, on the tween's completion.
2. The move is a plain `target.appendChild(canvas)`. `appendChild` on a node that already has a parent relocates it rather than copying it, and a WebGL context belongs to the canvas element, not to its position in the document — so the context, the compiled program and the drawing buffer all survive being detached and re-attached.
3. The flight is FLIP: `scaleX = first.width / last.width`, `scaleY = first.height / last.height`, and the translation is the difference between the two centre points. Scale, never `width`/`height` — a width tween is 0.72 s of continuous reflow, and writing the canvas's width attribute would reallocate the drawing buffer on every frame of it.
4. The buffer resize is deferred to `onComplete` and the `ResizeObserver` is muted for the duration by a `flying` flag. Assigning `canvas.width` is what reallocates and CLEARS the backing store; do it while the element is mid-flight and the object being animated goes black for a frame. During the flight the old buffer is simply scaled by CSS, which is slightly soft and completely invisible at this duration.
5. Measuring inside the handler also makes interruption correct for free. If a flight is already running, `getBoundingClientRect()` returns the TRANSFORMED rect — where the canvas visually is, mid-air — so a second click continues from that point instead of snapping back to the slot it was heading for.
6. The destination slot has its `overflow` forced to `visible` inline for the length of the flight and restored afterwards. Inline, because the clipping usually comes from a utility class on the consumer's own markup that a rule in our stylesheet would lose to.
7. A `uHandoff` uniform is tweened 0 to 1 and back on the same timeline, shearing the field's sampling coordinate. It disturbs the picture rather than fading it: a fade would hide precisely the frames that prove the surface is the same one.

## Constants — use these values

These are tuned. Each one has a reason; do not round them off or replace them
with values that "look cleaner".

- `HANDOFF_DURATION = 0.72`
  Seconds for the flight. It is long for a page transition, deliberately — the whole point is that you can watch a specific fold of the field travel and see that it never restarts. Under about 0.45 s the eye cannot track it and the effect degrades to a cut; over about 1 s the route change starts to feel withheld.
- `HANDOFF_EASE = 'power3.inOut'`
  Symmetric, because the two rectangles are equally important — the canvas is not leaving somewhere for somewhere, it is the same object being re-placed. An `out` ease would put the acceleration at the origin and imply the old page was the one that mattered.
- `SURGE_EASE = 'sine.inOut'`
  The `uHandoff` shear runs half the flight forward and half back (`yoyo: true, repeat: 1`). Sine, because it is the only common ease with zero slope at both ends, so the disturbance grows out of stillness and returns to it with no visible corner at the turnaround.
- `LEVELS = 6`
  Tones the field is crushed to. Low enough that the quantisation edges are hard and locked to the drawing buffer, which is what makes the mid-flight scaling legible as "these are the pixels that were already there". Above about ten the picture is a smooth gradient again and the proof is lost.
- `CELL = 2`
  Dither cell size in DRAWING-BUFFER pixels, so on a 2x display the pattern is one CSS pixel per cell — fine enough to read as tone, coarse enough to survive the scaling. Measured in buffer pixels rather than CSS pixels on purpose: it is the buffer that stretches during the flight.
- `MAX_DPR = 2`
  Device pixel ratio ceiling. Above 2 the visual gain on a quantised field is nil and the fill-rate cost is quadratic — and this surface is on screen on every page rather than on one hero, so its cost is paid continuously.
- `INK / PAPER = [0.42, 0.65, 1] / [0.035, 0.037, 0.045]`
  The two ends of the quantisation ramp, as raw 0..1 RGB. Passed as `THREE.Vector3` rather than `THREE.Color` because three applies working-colour-space conversion to a Color and none to a Vector3, and a `ShaderMaterial` emits `gl_FragColor` without the output-encoding chunk — Vector3 is the only pairing where the numbers written in the source are the numbers that reach the screen.

## API

```ts
function CanvasHandoffTransition(props: { controller: CanvasHandoff; children: ReactNode; duration?: number; ease?: string; ink?: readonly [number, number, number]; paper?: readonly [number, number, number]; levels?: number; cell?: number; maxDpr?: number; className?: string }): JSX.Element
function CanvasHandoffSlot(props: { controller: CanvasHandoff; id: string; className?: string }): JSX.Element
function useCanvasHandoff(initialId: string): { active: string; fallback: boolean; handoff: (id: string, commit?: () => void) => void }
```

- `controller: CanvasHandoff`
  From `useCanvasHandoff(initialSlotId)`. Shared by the host and every slot.
- `children: ReactNode`
  The pages. Each renders one `CanvasHandoffSlot` for its own landing place.
- `duration: number` — default `0.72`
  Seconds for the flight. `0` moves the canvas instantly with no tween.
- `ease: string` — default `'power3.inOut'`
  Easing for the flight.
- `ink: readonly [number, number, number]` — default `[0.42, 0.65, 1]`
  Bright end of the quantisation ramp, raw 0..1 RGB.
- `paper: readonly [number, number, number]` — default `[0.035, 0.037, 0.045]`
  Dark end of the quantisation ramp, raw 0..1 RGB.
- `levels: number` — default `6`
  How many tones the field is crushed to.
- `cell: number` — default `2`
  Dither cell size in drawing-buffer pixels.
- `maxDpr: number` — default `2`
  Device pixel ratio ceiling.
- `className: string`
  Merged onto the host root via `cn`.
- `id: string`
  `CanvasHandoffSlot` only. Stable identity for this landing place, unique within one controller.

## Reference implementation of the core

This is the part that is hard to get right. Adapt it; do not treat the rest of
the component as fixed.

```tsx
/* ------------------------------------------------------------------------
   BUILD. Depends on the motion preference and the DPR ceiling and nothing
   else — emphatically not on the active slot, or a route change would tear
   down the very context the component exists to preserve.
   ------------------------------------------------------------------------ */
useEffect(() => {
  const rootEl = root.current;
  if (!rootEl) return;

  /* Probed in an effect, never during render: `hasWebGL()` needs `document`,
     and branching the markup on it would be a hydration mismatch rather than
     a graceful degradation. */
  if (!hasWebGL()) {
    setFallback(true);
    return;
  }
  setFallback(false);

  const renderer = new THREE.WebGLRenderer({
    antialias: false,
    alpha: false,
    powerPreference: 'low-power',
  });
  renderer.setPixelRatio(pixelRatio(maxDpr));

  /* three constructs the canvas; React never sees it. This is the whole
     strict-mode story: the second invocation gets a brand new element and a
     brand new context, because the first invocation's cleanup removed and
     released its own. */
  const canvas = renderer.domElement;
  canvas.className = 'kx-handoff-canvas';
  canvas.setAttribute('aria-hidden', 'true');

  const l = look.current;
  const uniforms: HandoffUniforms = {
    uResolution: { value: new THREE.Vector2(1, 1) },
    uTime: { value: 0 },
    uHandoff: { value: 0 },
    uLevels: { value: l.levels },
    uCell: { value: l.cell },
    /* Vector3, not Color: three applies working-colour-space conversion to a
       Color and none to a Vector3, and a `ShaderMaterial` emits `gl_FragColor`
       without the output-encoding chunk. Vector3 is the only pair of ends
       where the numbers written here are the numbers that reach the screen. */
    uInk: { value: new THREE.Vector3(l.ink0, l.ink1, l.ink2) },
    uPaper: { value: new THREE.Vector3(l.paper0, l.paper1, l.paper2) },
  };

  const material = new THREE.ShaderMaterial({
    vertexShader: canvasHandoffVertexShader,
    fragmentShader: canvasHandoffFragmentShader,
    uniforms,
  });
  // A 2x2 plane under an identity ortho camera is a full-screen quad with no
  // projection maths and no camera to keep in sync on resize.
  const geometry = new THREE.PlaneGeometry(2, 2);
  const scene = new THREE.Scene();
  scene.add(new THREE.Mesh(geometry, material));
  const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);

  const render = () => renderer.render(scene, camera);

  const resize = () => {
    const host = canvas.parentElement;
    if (!host) return;
    const w = host.clientWidth;
    const h = host.clientHeight;
    if (w === 0 || h === 0) return;
    // `false` leaves the CSS size alone; the stylesheet's 100%/100% is what
    // keeps the canvas glued to whichever slot currently owns it.
    renderer.setSize(w, h, false);
    uniforms.uResolution.value.set(w, h);
    render();
  };

  // Whatever slot is active right now. `rootEl` is the safety net for a
  // controller whose slots have not registered — the canvas is visible and
  // full-bleed rather than orphaned.
  const home = slots.current.get(activeRef.current) ?? rootEl;
  home.appendChild(canvas);
  resize();

  /* Observed on the CANVAS, not the slot: the observation follows the node
     across the handoff, so one observer covers every slot it will ever live
     in. `setSize(..., false)` never writes the CSS box, so this cannot feed
     back on itself. */
  const observer = new ResizeObserver(() => {
    // The flight deliberately runs at the OLD buffer size, scaled. Honouring
    // the resize here is the black frame the deferral exists to prevent.
    if (engine.current?.flying) return;
    resize();
  });
  observer.observe(canvas);

  if (reduced) {
    // One frame, then nothing. A field that drifts forever is motion, and
    // motion is what was asked not to happen.
    render();
  } else {
    // The timestamp is absolute, so elapsed time survives the handoff with
    // no accumulator to carry across and nothing to reset.
    renderer.setAnimationLoop((time) => {
      uniforms.uTime.value = time / 1000;
      render();
    });
  }

  engine.current = { canvas, uniforms, render, resize, flying: false };

  return () => {
    observer.disconnect();
    gsap.killTweensOf(canvas);
    gsap.killTweensOf(uniforms.uHandoff);
    // Stops the loop before releasing the context; a queued frame rendering
    // into a lost context throws out of the cleanup.
    disposeRenderer(renderer);
    material.dispose();
    geometry.dispose();
    // The effect appended it, the effect removes it. React has no idea this
    // node was ever here and will not try to reconcile it.
    canvas.remove();
    engine.current = null;
  };
}, [reduced, maxDpr, slots, engine, activeRef, setFallback]);

/* ------------------------------------------------------------------------
   HAND OVER. A LAYOUT effect, because React's mutations and its layout
   effects share one task: the canvas leaves the old parent and arrives at
   the new one with no paint in between. The same code in `useEffect` shows a
   frame with a hole where the canvas used to be.
   ------------------------------------------------------------------------ */
useLayoutEffect(() => {
  const e = engine.current;
  const target = slots.current.get(active);
  if (!e || !target) return;

  const from = first.current;
  first.current = null;

  // First run after the build already parked it correctly.
  if (e.canvas.parentElement === target) return;

  // `appendChild` on a node that already has a parent MOVES it. No clone, no
  // re-creation, and the WebGL context rides along untouched.
  target.appendChild(e.canvas);

  /* An interrupted flight leaves an inline transform behind. Clear it before
     measuring or Last is read through the previous flight's scale, and the
     inversion comes out compounded — the canvas leaps somewhere neither slot
     has ever been. `clearProps` rather than a set to identity so the instant
     paths below leave the element genuinely untouched. */
  gsap.killTweensOf(e.canvas);
  gsap.set(e.canvas, { clearProps: 'transform' });

  if (!from || reduced || duration <= 0) {
    e.resize();
    return;
  }

  const to = e.canvas.getBoundingClientRect();
  if (to.width === 0 || to.height === 0) {
    e.resize();
    return;
  }

  // FLIP: invert the measured difference, then animate the inversion away.
  // Scale rather than width/height — width is a layout property and a layout
  // property tweened for 0.7 s is 0.7 s of reflow.
  const scaleX = from.width / to.width;
  const scaleY = from.height / to.height;
  const x = from.left + from.width / 2 - (to.left + to.width / 2);
  const y = from.top + from.height / 2 - (to.top + to.height / 2);

  /* For the length of the flight the canvas is bigger than, and outside, the
     slot it now lives in — so the slot must not clip. Written INLINE and
     restored afterwards, because the thing being overridden is usually a
     utility class on the consumer's own markup, and a rule in our stylesheet
     would lose to it. Skip this and the transition is invisible: the canvas
     is cropped to its destination rectangle from the first frame, which
     looks exactly like no transition at all. */
  const clipped = target.style.overflow;
  target.style.overflow = 'visible';
  const unclip = () => {
    target.style.overflow = clipped;
  };

  e.flying = true;
  const tl = gsap.timeline({
    onComplete: () => {
      e.flying = false;
      unclip();
      gsap.set(e.canvas, { clearProps: 'transform' });
      // The ONE buffer resize, now that nothing is moving.
      e.resize();
    },
  });

  tl.fromTo(
    e.canvas,
    { x, y, scaleX, scaleY, transformOrigin: '50% 50%' },
    { x: 0, y: 0, scaleX: 1, scaleY: 1, duration, ease },
    0,
  ).fromTo(
    e.uniforms.uHandoff,
    { value: 0 },
    { value: 1, duration: duration / 2, ease: SURGE_EASE, yoyo: true, repeat: 1 },
    0,
  );

  return () => {
    tl.kill();
    unclip();
    e.flying = false;
  };
}, [active, duration, ease, reduced, engine, slots, first]);
```

## Requirements

These are not optional. A version of this component that skips any of them is
not finished.

**Cleanup.** `disposeRenderer` stops the animation loop before releasing the context, so a queued frame cannot render into a lost one and throw out of the cleanup; it then calls `dispose()`, `forceContextLoss()` and zeroes the canvas dimensions to release the backing store immediately. The `ResizeObserver` is disconnected, both the canvas transform tweens and the `uHandoff` tween are killed, the material and geometry are disposed, and the canvas element is removed by the same effect that appended it. The flight effect's own cleanup kills its timeline and restores the destination slot's original `overflow`, so an unmount mid-flight cannot leave a slot permanently unclipped.

**Reduced motion.** No animation loop is started: one frame is drawn when the context is built, and one more after each move and each resize, so the field is a still image rather than a drifting one. The handoff still moves the canvas — that is a DOM relocation, not motion — but the FLIP tween and the `uHandoff` surge are both skipped, and the drawing buffer is resized immediately instead of on a completion that will never fire. `prefers-reduced-motion` is read by the component through `useReducedMotion`, never inferred from the page.

**Frame-rate independence.** Any smoothing must be a function of delta time.
`x += (target - x) * 0.1` converges twice as fast at 120 Hz as at 60 Hz; use
`x = target + (x - target) * Math.pow(smoothing, dt)` instead, where
`smoothing` is the fraction of the gap remaining after one second.

**Strict mode.** Assume effects run twice in development. Setup must be
idempotent and teardown must fully undo it.

## Known pitfalls — avoid these specifically

- Rendering the canvas from JSX. React unmounts the outgoing page's subtree on a route change, and a `<canvas ref>` inside it is destroyed with it — context, program, buffer and all. That is the exact failure this component exists to avoid, and it cannot be worked around from inside the page: the canvas has to be created by the effect and owned by nobody React can see.
- A React-owned canvas under `reactStrictMode`. The strict-mode remount reuses the SAME element across both effect invocations, so the first cleanup's `forceContextLoss()` hands the second invocation a dead context and the component renders permanently blank — in development only, which is a genuinely expensive way to lose an afternoon. Creating the element inside the effect gives the second run a clean one.
- Doing the move in `useEffect` instead of `useLayoutEffect`. Passive effects run after the browser has painted, so there is one composited frame in which the outgoing page is gone and the canvas has not arrived. It reads as a flash of empty box at exactly the moment the component is supposed to be proving continuity.
- Measuring First inside the layout effect. By then React has already removed the outgoing subtree, so there is nothing left to measure and the FLIP either animates from the wrong rectangle or is skipped. The measurement has to happen in the handler, before `setState`.
- Calling `renderer.setSize()` during the flight. Writing the canvas's width or height reallocates and clears the drawing buffer, so the object mid-animation flashes black for a frame. The `ResizeObserver` fires exactly then, because moving into a differently sized slot changes the border box — which is why it is muted by a `flying` flag rather than trusted.
- A destination slot that clips. `overflow: hidden` on the slot crops the inbound canvas to its final rectangle from the first frame, so the flight is invisible and the result looks identical to no transition. Forcing `overflow: visible` inline for the duration is the only override that reliably beats a utility class.
- Cloning instead of moving. `cloneNode(true)` on a canvas copies the element and NOT its bitmap or its context; `outerHTML` round-trips have the same effect. The clone is blank and the original is gone, so the page shows an empty box with no error anywhere.
- Tweening `width` and `height` instead of `scale`. Besides reflowing on every frame, they are the two properties that reset the drawing buffer, so the animation would clear the surface sixty times a second.
- Mounting the host inside the routed subtree. If the host unmounts on navigation the context goes with it and there is nothing left to hand over — the component silently degrades to the mount-a-new-one behaviour it replaces. It belongs in the layout, above the router outlet.
- Assuming the canvas paints where you expect. It is `position: absolute; z-index: 1` so it can lift over static page content on the way past, which means anything meant to sit ON TOP of the field needs its own stacking context. Text without `relative z-10` disappears under the canvas for the length of the flight.
- Letting the shader carry any state that could reset. Everything visible is a function of the absolute `setAnimationLoop` timestamp, so there is no accumulator to hand over. Drive the field from a per-frame increment instead and it will still restart on the handoff — the exact artefact the component was built to eliminate, hidden one layer deeper.

## Notes

- In Next.js App Router: mount `CanvasHandoffTransition` in `app/layout.tsx`, put a `CanvasHandoffSlot` in each page with an id of its own, and replace `<Link>` with a button calling `handoff(slotId, () => router.push(href))`. The component imports nothing from `next/navigation` — the route change is a callback, so the same host works under React Router or, as in the preview, plain `useState`.
- The slot renders no children while WebGL is live. That is not cosmetic: React only reconciles children it created, so an empty child list is the guarantee that it will never touch the canvas sitting inside. Content that belongs over the field goes in a sibling, not inside the slot.
- One context for the whole site is the real prize. Chromium allows roughly sixteen live WebGL contexts per renderer process and evicts the oldest without warning when you exceed it — a per-page canvas on a site with a persistent background is how an unrelated component three routes away turns black.
- The field is deliberately slow, about eight seconds per visible cycle. Anything faster and the eye cannot hold a specific fold across the transition, which is the only direct evidence the viewer gets that the surface was not rebuilt.
- Under reduced motion there is no animation loop at all — a single frame is drawn on build and again after each move — so the component costs nothing per frame for the people most likely to be on the weakest hardware.

## Definition of done

Watch one bright fold of the field and click a nav item: the same fold is still there when the canvas lands, offset only by the time the flight took. Nothing blanks, blinks, or restarts. The canvas visibly changes proportion in flight — wide banner to tall column — with the dither cells stretching along with it, then snapping to square at the end when the buffer is finally resized. Click twice in quick succession and the second flight starts from wherever the canvas is in the air rather than jumping to the first destination. In React DevTools the canvas element has no owner. Under `reactStrictMode` in development the preview renders on first load, not blank. With reduced motion the field is a still image and the canvas relocates without a flight.

When you are finished, tell me which of the requirements above you implemented
and where, so I can check them.
346 lines · 3619 words
04

Source

4 files, exactly as installed.

installs tocomponents/motion/canvas-handoff-transition.tsx
'use client';

import gsap from 'gsap';
import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
  type RefObject,
} from 'react';
import * as THREE from 'three';
import { cn } from '@/lib/utils';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { disposeRenderer, hasWebGL, pixelRatio } from '@/lib/webgl';
import {
  canvasHandoffFragmentShader,
  canvasHandoffVertexShader,
} from '@/components/motion/shaders/canvas-handoff-transition';

/* ==========================================================================
   Canvas Handoff Transition
   --------------------------------------------------------------------------
   A WebGL surface that SURVIVES the route change. The destination page does
   not build a second renderer and crossfade to it — it is handed the live
   canvas, still holding its context, still mid-frame, and the canvas flies
   from the outgoing slot's rectangle to the incoming one.

   The normal way to put a shader on two pages is to mount it on both and let
   each own its context. That costs a fresh compile, a fresh first frame and,
   on a page with several of them, an eviction: Chromium caps live contexts per
   RENDERER PROCESS and quietly kills the oldest to make room. It also blinks.
   No amount of easing hides a shader restarting from t = 0.

   The four moves, in the order they have to happen:

     1. MEASURE FIRST, before React knows anything. `handoff()` reads the
        canvas rect inside the click handler. One `setState` later the old
        slot is gone and that number is unrecoverable.
     2. FLUSH one frame, so the pixels about to travel are this frame's, not
        the last idle frame's.
     3. MOVE in a LAYOUT effect. React's DOM mutations and layout effects run
        in the same task, so the canvas is detached and re-attached without a
        paint in between. In a passive `useEffect` the browser has already
        painted a frame with a hole in it.
     4. RESIZE LAST, on the tween's completion. Writing `canvas.width` is what
        reallocates and CLEARS the drawing buffer; do it mid-flight and the
        thing you are animating goes black for a frame.

   REACT MUST NEVER OWN THE CANVAS. Not as `<canvas ref>`, not as JSX at all.
   React owns the empty slot `<div>`s and nothing else. Two reasons, and the
   second one is the one that bites:

     - React unmounts the outgoing page's subtree on a route change. A canvas
       rendered by that subtree is destroyed with it, context and all, which is
       the exact failure this component exists to avoid.
     - `reactStrictMode` double-invokes effects in development. A React-owned
       canvas is the SAME node across both invocations, so the first cleanup's
       `forceContextLoss()` hands the second invocation a dead context and the
       component renders permanently blank — in dev only, which is a genuinely
       nasty way to lose an afternoon. Creating the canvas inside the effect
       and removing it in the cleanup makes the second run a clean slate.
   ========================================================================== */

/** Seconds for the flight between slots. */
export const HANDOFF_DURATION = 0.72;
/** Easing for the flight. */
export const HANDOFF_EASE = 'power3.inOut';

const SURGE_EASE = 'sine.inOut';
const INK: readonly [number, number, number] = [0.42, 0.65, 1];
const PAPER: readonly [number, number, number] = [0.035, 0.037, 0.045];
const LEVELS = 6;
const CELL = 2;
const MAX_DPR = 2;

/* The canvas is absolutely positioned so it fills its slot exactly at rest and
   can overflow it freely during the flight; `z-index: 1` lifts it over static
   page content on the way past. Anything that must sit ON TOP of the field
   needs its own stacking context (`relative z-10` and up).

   Declared here rather than inline: React re-renders during a route change,
   and a `style` prop would fight the transform GSAP is writing to the same
   element on the same frame. */
const HANDOFF_CSS = `
.kx-handoff-slot { position: relative; }
.kx-handoff-canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  z-index: 1;
  pointer-events: none;
}
.kx-handoff-fallback {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    radial-gradient(120% 90% at 28% 18%, var(--kx-handoff-ink, #6ba6ff) 0%, transparent 62%),
    var(--kx-handoff-paper, #09090b);
  opacity: 0.55;
}
`;

/* The index signature is what `ShaderMaterial.uniforms` is declared as; the
   named members sit alongside it so `uniforms.uHandoff.value` is a `number`
   at the call sites rather than whatever the index signature widens to. */
interface HandoffUniforms {
  [key: string]: THREE.IUniform<unknown>;
  uResolution: THREE.IUniform<THREE.Vector2>;
  uTime: THREE.IUniform<number>;
  uHandoff: THREE.IUniform<number>;
  uLevels: THREE.IUniform<number>;
  uCell: THREE.IUniform<number>;
  uInk: THREE.IUniform<THREE.Vector3>;
  uPaper: THREE.IUniform<THREE.Vector3>;
}

interface HandoffEngine {
  canvas: HTMLCanvasElement;
  uniforms: HandoffUniforms;
  /** Draw one frame right now. */
  render: () => void;
  /** Match the drawing buffer to the current slot, then draw. */
  resize: () => void;
  /** True for the length of a flight; suppresses the deferred resize. */
  flying: boolean;
}

interface CanvasHandoffInternal {
  slots: RefObject<Map<string, HTMLDivElement>>;
  engine: RefObject<HandoffEngine | null>;
  first: RefObject<DOMRect | null>;
  activeRef: RefObject<string>;
  setFallback: (value: boolean) => void;
}

export interface CanvasHandoff {
  /** Slot id currently holding the live canvas. */
  active: string;
  /** True once the host has determined WebGL is unavailable. */
  fallback: boolean;
  /**
   * Hand the canvas to `id` and run `commit` in the same task. `commit` is
   * where the route actually changes — `setState`, `router.push`, whatever.
   * Safe to call again mid-flight: the new flight starts from where the canvas
   * visually is, not from where it was going.
   */
  handoff: (id: string, commit?: () => void) => void;
  /** @internal wiring between the slots and the host. Not part of the API. */
  internal: CanvasHandoffInternal;
}

/**
 * Owns the slot registry and the active id. Call once, high enough in the tree
 * that it outlives every page it hands the canvas between.
 */
export function useCanvasHandoff(initialId: string): CanvasHandoff {
  const slots = useRef<Map<string, HTMLDivElement>>(new Map());
  const engine = useRef<HandoffEngine | null>(null);
  const first = useRef<DOMRect | null>(null);
  const activeRef = useRef(initialId);
  const [active, setActive] = useState(initialId);
  const [fallback, setFallback] = useState(false);

  const handoff = useCallback((id: string, commit?: () => void) => {
    const e = engine.current;
    if (e) {
      /* Measured HERE, in the handler, because this is the last moment the
         outgoing rectangle exists. If a flight is already running this reads
         the transformed rect — the canvas's visual position, mid-air — which
         is exactly what an interrupted FLIP should continue from. */
      first.current = e.canvas.getBoundingClientRect();
      /* One frame, now. A canvas that is detached and re-attached inside a
         single task is never repainted, so whatever sits in the drawing buffer
         at the moment of the move is what the viewer sees for that frame. */
      e.render();
    }
    activeRef.current = id;
    setActive(id);
    commit?.();
  }, []);

  return useMemo<CanvasHandoff>(
    () => ({
      active,
      fallback,
      handoff,
      internal: { slots, engine, first, activeRef, setFallback },
    }),
    [active, fallback, handoff],
  );
}

export interface CanvasHandoffSlotProps {
  controller: CanvasHandoff;
  /** Stable identity for this landing place. Unique within one controller. */
  id: string;
  className?: string;
}

/**
 * An empty box that says "the canvas belongs here on this page". It renders no
 * children while WebGL is live, so React has an empty child list for this node
 * and never touches the canvas sitting inside it.
 */
export function CanvasHandoffSlot({ controller, id, className }: CanvasHandoffSlotProps) {
  const { slots } = controller.internal;
  const mine = useRef<HTMLDivElement | null>(null);

  const ref = useCallback(
    (el: HTMLDivElement | null) => {
      const map = slots.current;
      if (el) {
        mine.current = el;
        map.set(id, el);
        return;
      }
      /* Identity-guarded: two pages may legitimately use the same id, and
         React detaches the outgoing ref before attaching the incoming one.
         Deleting blindly would drop a registration that is already correct. */
      if (map.get(id) === mine.current) map.delete(id);
      mine.current = null;
    },
    [slots, id],
  );

  return (
    <div ref={ref} className={cn('kx-handoff-slot', className)} aria-hidden>
      {controller.fallback ? <div className="kx-handoff-fallback" /> : null}
    </div>
  );
}

export interface CanvasHandoffTransitionProps {
  controller: CanvasHandoff;
  /** The pages. Each renders a `CanvasHandoffSlot` for its own landing place. */
  children: ReactNode;
  /** Seconds for the flight between slots. */
  duration?: number;
  /** Easing for the flight. */
  ease?: string;
  /** Bright end of the quantisation ramp, linear 0..1 RGB. */
  ink?: readonly [number, number, number];
  /** Dark end of the quantisation ramp, linear 0..1 RGB. */
  paper?: readonly [number, number, number];
  /** How many tones the field is crushed to. */
  levels?: number;
  /** Dither cell size in drawing-buffer pixels. */
  cell?: number;
  /** Device pixel ratio ceiling. */
  maxDpr?: number;
  className?: string;
}

/**
 * The host. Mount once, outside every page it serves — if this unmounts, the
 * context goes with it and there is nothing left to hand over.
 */
export function CanvasHandoffTransition({
  controller,
  children,
  duration = HANDOFF_DURATION,
  ease = HANDOFF_EASE,
  ink = INK,
  paper = PAPER,
  levels = LEVELS,
  cell = CELL,
  maxDpr = MAX_DPR,
  className,
}: CanvasHandoffTransitionProps) {
  const root = useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();
  const { slots, engine, first, activeRef, setFallback } = controller.internal;
  const active = controller.active;

  const [ink0, ink1, ink2] = ink;
  const [paper0, paper1, paper2] = paper;

  /* Read by the builder so a rebuild picks up the current look without the
     look being a dependency of the rebuild. */
  const look = useRef({ ink0, ink1, ink2, paper0, paper1, paper2, levels, cell });
  look.current = { ink0, ink1, ink2, paper0, paper1, paper2, levels, cell };

  // #region critical
  /* ------------------------------------------------------------------------
     BUILD. Depends on the motion preference and the DPR ceiling and nothing
     else — emphatically not on the active slot, or a route change would tear
     down the very context the component exists to preserve.
     ------------------------------------------------------------------------ */
  useEffect(() => {
    const rootEl = root.current;
    if (!rootEl) return;

    /* Probed in an effect, never during render: `hasWebGL()` needs `document`,
       and branching the markup on it would be a hydration mismatch rather than
       a graceful degradation. */
    if (!hasWebGL()) {
      setFallback(true);
      return;
    }
    setFallback(false);

    const renderer = new THREE.WebGLRenderer({
      antialias: false,
      alpha: false,
      powerPreference: 'low-power',
    });
    renderer.setPixelRatio(pixelRatio(maxDpr));

    /* three constructs the canvas; React never sees it. This is the whole
       strict-mode story: the second invocation gets a brand new element and a
       brand new context, because the first invocation's cleanup removed and
       released its own. */
    const canvas = renderer.domElement;
    canvas.className = 'kx-handoff-canvas';
    canvas.setAttribute('aria-hidden', 'true');

    const l = look.current;
    const uniforms: HandoffUniforms = {
      uResolution: { value: new THREE.Vector2(1, 1) },
      uTime: { value: 0 },
      uHandoff: { value: 0 },
      uLevels: { value: l.levels },
      uCell: { value: l.cell },
      /* Vector3, not Color: three applies working-colour-space conversion to a
         Color and none to a Vector3, and a `ShaderMaterial` emits `gl_FragColor`
         without the output-encoding chunk. Vector3 is the only pair of ends
         where the numbers written here are the numbers that reach the screen. */
      uInk: { value: new THREE.Vector3(l.ink0, l.ink1, l.ink2) },
      uPaper: { value: new THREE.Vector3(l.paper0, l.paper1, l.paper2) },
    };

    const material = new THREE.ShaderMaterial({
      vertexShader: canvasHandoffVertexShader,
      fragmentShader: canvasHandoffFragmentShader,
      uniforms,
    });
    // A 2x2 plane under an identity ortho camera is a full-screen quad with no
    // projection maths and no camera to keep in sync on resize.
    const geometry = new THREE.PlaneGeometry(2, 2);
    const scene = new THREE.Scene();
    scene.add(new THREE.Mesh(geometry, material));
    const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);

    const render = () => renderer.render(scene, camera);

    const resize = () => {
      const host = canvas.parentElement;
      if (!host) return;
      const w = host.clientWidth;
      const h = host.clientHeight;
      if (w === 0 || h === 0) return;
      // `false` leaves the CSS size alone; the stylesheet's 100%/100% is what
      // keeps the canvas glued to whichever slot currently owns it.
      renderer.setSize(w, h, false);
      uniforms.uResolution.value.set(w, h);
      render();
    };

    // Whatever slot is active right now. `rootEl` is the safety net for a
    // controller whose slots have not registered — the canvas is visible and
    // full-bleed rather than orphaned.
    const home = slots.current.get(activeRef.current) ?? rootEl;
    home.appendChild(canvas);
    resize();

    /* Observed on the CANVAS, not the slot: the observation follows the node
       across the handoff, so one observer covers every slot it will ever live
       in. `setSize(..., false)` never writes the CSS box, so this cannot feed
       back on itself. */
    const observer = new ResizeObserver(() => {
      // The flight deliberately runs at the OLD buffer size, scaled. Honouring
      // the resize here is the black frame the deferral exists to prevent.
      if (engine.current?.flying) return;
      resize();
    });
    observer.observe(canvas);

    if (reduced) {
      // One frame, then nothing. A field that drifts forever is motion, and
      // motion is what was asked not to happen.
      render();
    } else {
      // The timestamp is absolute, so elapsed time survives the handoff with
      // no accumulator to carry across and nothing to reset.
      renderer.setAnimationLoop((time) => {
        uniforms.uTime.value = time / 1000;
        render();
      });
    }

    engine.current = { canvas, uniforms, render, resize, flying: false };

    return () => {
      observer.disconnect();
      gsap.killTweensOf(canvas);
      gsap.killTweensOf(uniforms.uHandoff);
      // Stops the loop before releasing the context; a queued frame rendering
      // into a lost context throws out of the cleanup.
      disposeRenderer(renderer);
      material.dispose();
      geometry.dispose();
      // The effect appended it, the effect removes it. React has no idea this
      // node was ever here and will not try to reconcile it.
      canvas.remove();
      engine.current = null;
    };
  }, [reduced, maxDpr, slots, engine, activeRef, setFallback]);

  /* ------------------------------------------------------------------------
     HAND OVER. A LAYOUT effect, because React's mutations and its layout
     effects share one task: the canvas leaves the old parent and arrives at
     the new one with no paint in between. The same code in `useEffect` shows a
     frame with a hole where the canvas used to be.
     ------------------------------------------------------------------------ */
  useLayoutEffect(() => {
    const e = engine.current;
    const target = slots.current.get(active);
    if (!e || !target) return;

    const from = first.current;
    first.current = null;

    // First run after the build already parked it correctly.
    if (e.canvas.parentElement === target) return;

    // `appendChild` on a node that already has a parent MOVES it. No clone, no
    // re-creation, and the WebGL context rides along untouched.
    target.appendChild(e.canvas);

    /* An interrupted flight leaves an inline transform behind. Clear it before
       measuring or Last is read through the previous flight's scale, and the
       inversion comes out compounded — the canvas leaps somewhere neither slot
       has ever been. `clearProps` rather than a set to identity so the instant
       paths below leave the element genuinely untouched. */
    gsap.killTweensOf(e.canvas);
    gsap.set(e.canvas, { clearProps: 'transform' });

    if (!from || reduced || duration <= 0) {
      e.resize();
      return;
    }

    const to = e.canvas.getBoundingClientRect();
    if (to.width === 0 || to.height === 0) {
      e.resize();
      return;
    }

    // FLIP: invert the measured difference, then animate the inversion away.
    // Scale rather than width/height — width is a layout property and a layout
    // property tweened for 0.7 s is 0.7 s of reflow.
    const scaleX = from.width / to.width;
    const scaleY = from.height / to.height;
    const x = from.left + from.width / 2 - (to.left + to.width / 2);
    const y = from.top + from.height / 2 - (to.top + to.height / 2);

    /* For the length of the flight the canvas is bigger than, and outside, the
       slot it now lives in — so the slot must not clip. Written INLINE and
       restored afterwards, because the thing being overridden is usually a
       utility class on the consumer's own markup, and a rule in our stylesheet
       would lose to it. Skip this and the transition is invisible: the canvas
       is cropped to its destination rectangle from the first frame, which
       looks exactly like no transition at all. */
    const clipped = target.style.overflow;
    target.style.overflow = 'visible';
    const unclip = () => {
      target.style.overflow = clipped;
    };

    e.flying = true;
    const tl = gsap.timeline({
      onComplete: () => {
        e.flying = false;
        unclip();
        gsap.set(e.canvas, { clearProps: 'transform' });
        // The ONE buffer resize, now that nothing is moving.
        e.resize();
      },
    });

    tl.fromTo(
      e.canvas,
      { x, y, scaleX, scaleY, transformOrigin: '50% 50%' },
      { x: 0, y: 0, scaleX: 1, scaleY: 1, duration, ease },
      0,
    ).fromTo(
      e.uniforms.uHandoff,
      { value: 0 },
      { value: 1, duration: duration / 2, ease: SURGE_EASE, yoyo: true, repeat: 1 },
      0,
    );

    return () => {
      tl.kill();
      unclip();
      e.flying = false;
    };
  }, [active, duration, ease, reduced, engine, slots, first]);
  // #endregion critical

  /* Look changes without a rebuild. Uniform writes are free; recompiling a
     program because someone tweaked a colour is not. */
  useEffect(() => {
    const e = engine.current;
    if (!e) return;
    e.uniforms.uInk.value.set(ink0, ink1, ink2);
    e.uniforms.uPaper.value.set(paper0, paper1, paper2);
    e.uniforms.uLevels.value = levels;
    e.uniforms.uCell.value = cell;
    // Under reduced motion there is no loop to pick the change up.
    e.render();
  }, [ink0, ink1, ink2, paper0, paper1, paper2, levels, cell, engine]);

  const rgb = (r: number, g: number, b: number) =>
    `rgb(${Math.round(r * 255)} ${Math.round(g * 255)} ${Math.round(b * 255)})`;

  return (
    <div
      ref={root}
      className={cn('relative', className)}
      /* Only these two are inline. They are read by CSS and never touched by
         GSAP, so a re-render cannot fight a tween over them — the rule that
         keeps animated custom properties out of `style` does not apply. */
      style={
        {
          '--kx-handoff-ink': rgb(ink0, ink1, ink2),
          '--kx-handoff-paper': rgb(paper0, paper1, paper2),
        } as CSSProperties
      }
    >
      <style href="kinetic-canvas-handoff-transition" precedence="medium">
        {HANDOFF_CSS}
      </style>
      {children}
    </div>
  );
}
05

API

Every parameter, its type and what happens when you change it.

function CanvasHandoffTransition(props: { controller: CanvasHandoff; children: ReactNode; duration?: number; ease?: string; ink?: readonly [number, number, number]; paper?: readonly [number, number, number]; levels?: number; cell?: number; maxDpr?: number; className?: string }): JSX.Element
function CanvasHandoffSlot(props: { controller: CanvasHandoff; id: string; className?: string }): JSX.Element
function useCanvasHandoff(initialId: string): { active: string; fallback: boolean; handoff: (id: string, commit?: () => void) => void }
Parameters
controllerCanvasHandoffFrom useCanvasHandoff(initialSlotId). Shared by the host and every slot.
childrenReactNodeThe pages. Each renders one CanvasHandoffSlot for its own landing place.
durationnumber = 0.72Seconds for the flight. 0 moves the canvas instantly with no tween.
easestring = 'power3.inOut'Easing for the flight.
inkreadonly [number, number, number] = [0.42, 0.65, 1]Bright end of the quantisation ramp, raw 0..1 RGB.
paperreadonly [number, number, number] = [0.035, 0.037, 0.045]Dark end of the quantisation ramp, raw 0..1 RGB.
levelsnumber = 6How many tones the field is crushed to.
cellnumber = 2Dither cell size in drawing-buffer pixels.
maxDprnumber = 2Device pixel ratio ceiling.
classNamestringMerged onto the host root via cn.
idstringCanvasHandoffSlot only. Stable identity for this landing place, unique within one controller.
06

How it works

The technique in order, not the code in order.

  1. 01

    Four steps in a fixed order. MEASURE first, inside the click handler, because one setState later the outgoing rectangle no longer exists. FLUSH one frame, so the pixels about to travel are current. MOVE in a useLayoutEffect, which shares its task with React's DOM mutations and therefore straddles no paint. RESIZE last, on the tween's completion.

  2. 02

    The move is a plain target.appendChild(canvas). appendChild on a node that already has a parent relocates it rather than copying it, and a WebGL context belongs to the canvas element, not to its position in the document — so the context, the compiled program and the drawing buffer all survive being detached and re-attached.

  3. 03

    The flight is FLIP: scaleX = first.width / last.width, scaleY = first.height / last.height, and the translation is the difference between the two centre points. Scale, never width/height — a width tween is 0.72 s of continuous reflow, and writing the canvas's width attribute would reallocate the drawing buffer on every frame of it.

  4. 04

    The buffer resize is deferred to onComplete and the ResizeObserver is muted for the duration by a flying flag. Assigning canvas.width is what reallocates and CLEARS the backing store; do it while the element is mid-flight and the object being animated goes black for a frame. During the flight the old buffer is simply scaled by CSS, which is slightly soft and completely invisible at this duration.

  5. 05

    Measuring inside the handler also makes interruption correct for free. If a flight is already running, getBoundingClientRect() returns the TRANSFORMED rect — where the canvas visually is, mid-air — so a second click continues from that point instead of snapping back to the slot it was heading for.

  6. 06

    The destination slot has its overflow forced to visible inline for the length of the flight and restored afterwards. Inline, because the clipping usually comes from a utility class on the consumer's own markup that a rule in our stylesheet would lose to.

  7. 07

    A uHandoff uniform is tweened 0 to 1 and back on the same timeline, shearing the field's sampling coordinate. It disturbs the picture rather than fading it: a fade would hide precisely the frames that prove the surface is the same one.

Load-bearing constants

Load-bearing constants
HANDOFF_DURATION = 0.72Seconds for the flight. It is long for a page transition, deliberately — the whole point is that you can watch a specific fold of the field travel and see that it never restarts. Under about 0.45 s the eye cannot track it and the effect degrades to a cut; over about 1 s the route change starts to feel withheld.
HANDOFF_EASE = 'power3.inOut'Symmetric, because the two rectangles are equally important — the canvas is not leaving somewhere for somewhere, it is the same object being re-placed. An out ease would put the acceleration at the origin and imply the old page was the one that mattered.
SURGE_EASE = 'sine.inOut'The uHandoff shear runs half the flight forward and half back (yoyo: true, repeat: 1). Sine, because it is the only common ease with zero slope at both ends, so the disturbance grows out of stillness and returns to it with no visible corner at the turnaround.
LEVELS = 6Tones the field is crushed to. Low enough that the quantisation edges are hard and locked to the drawing buffer, which is what makes the mid-flight scaling legible as "these are the pixels that were already there". Above about ten the picture is a smooth gradient again and the proof is lost.
CELL = 2Dither cell size in DRAWING-BUFFER pixels, so on a 2x display the pattern is one CSS pixel per cell — fine enough to read as tone, coarse enough to survive the scaling. Measured in buffer pixels rather than CSS pixels on purpose: it is the buffer that stretches during the flight.
MAX_DPR = 2Device pixel ratio ceiling. Above 2 the visual gain on a quantised field is nil and the fill-rate cost is quadratic — and this surface is on screen on every page rather than on one hero, so its cost is paid continuously.
INK / PAPER = [0.42, 0.65, 1] / [0.035, 0.037, 0.045]The two ends of the quantisation ramp, as raw 0..1 RGB. Passed as THREE.Vector3 rather than THREE.Color because three applies working-colour-space conversion to a Color and none to a Vector3, and a ShaderMaterial emits gl_FragColor without the output-encoding chunk — Vector3 is the only pairing where the numbers written in the source are the numbers that reach the screen.
07

Behaviour

What it does about motion sensitivity, teardown and being finished.

Reduced motion

No animation loop is started: one frame is drawn when the context is built, and one more after each move and each resize, so the field is a still image rather than a drifting one. The handoff still moves the canvas — that is a DOM relocation, not motion — but the FLIP tween and the uHandoff surge are both skipped, and the drawing buffer is resized immediately instead of on a completion that will never fire. prefers-reduced-motion is read by the component through useReducedMotion, never inferred from the page.

Cleanup

disposeRenderer stops the animation loop before releasing the context, so a queued frame cannot render into a lost one and throw out of the cleanup; it then calls dispose(), forceContextLoss() and zeroes the canvas dimensions to release the backing store immediately. The ResizeObserver is disconnected, both the canvas transform tweens and the uHandoff tween are killed, the material and geometry are disposed, and the canvas element is removed by the same effect that appended it. The flight effect's own cleanup kills its timeline and restores the destination slot's original overflow, so an unmount mid-flight cannot leave a slot permanently unclipped.

Definition of done

Watch one bright fold of the field and click a nav item: the same fold is still there when the canvas lands, offset only by the time the flight took. Nothing blanks, blinks, or restarts. The canvas visibly changes proportion in flight — wide banner to tall column — with the dither cells stretching along with it, then snapping to square at the end when the buffer is finally resized. Click twice in quick succession and the second flight starts from wherever the canvas is in the air rather than jumping to the first destination. In React DevTools the canvas element has no owner. Under reactStrictMode in development the preview renders on first load, not blank. With reduced motion the field is a still image and the canvas relocates without a flight.

Pitfalls

  • Rendering the canvas from JSX. React unmounts the outgoing page's subtree on a route change, and a <canvas ref> inside it is destroyed with it — context, program, buffer and all. That is the exact failure this component exists to avoid, and it cannot be worked around from inside the page: the canvas has to be created by the effect and owned by nobody React can see.
  • A React-owned canvas under reactStrictMode. The strict-mode remount reuses the SAME element across both effect invocations, so the first cleanup's forceContextLoss() hands the second invocation a dead context and the component renders permanently blank — in development only, which is a genuinely expensive way to lose an afternoon. Creating the element inside the effect gives the second run a clean one.
  • Doing the move in useEffect instead of useLayoutEffect. Passive effects run after the browser has painted, so there is one composited frame in which the outgoing page is gone and the canvas has not arrived. It reads as a flash of empty box at exactly the moment the component is supposed to be proving continuity.
  • Measuring First inside the layout effect. By then React has already removed the outgoing subtree, so there is nothing left to measure and the FLIP either animates from the wrong rectangle or is skipped. The measurement has to happen in the handler, before setState.
  • Calling renderer.setSize() during the flight. Writing the canvas's width or height reallocates and clears the drawing buffer, so the object mid-animation flashes black for a frame. The ResizeObserver fires exactly then, because moving into a differently sized slot changes the border box — which is why it is muted by a flying flag rather than trusted.
  • A destination slot that clips. overflow: hidden on the slot crops the inbound canvas to its final rectangle from the first frame, so the flight is invisible and the result looks identical to no transition. Forcing overflow: visible inline for the duration is the only override that reliably beats a utility class.
  • Cloning instead of moving. cloneNode(true) on a canvas copies the element and NOT its bitmap or its context; outerHTML round-trips have the same effect. The clone is blank and the original is gone, so the page shows an empty box with no error anywhere.
  • Tweening width and height instead of scale. Besides reflowing on every frame, they are the two properties that reset the drawing buffer, so the animation would clear the surface sixty times a second.
  • Mounting the host inside the routed subtree. If the host unmounts on navigation the context goes with it and there is nothing left to hand over — the component silently degrades to the mount-a-new-one behaviour it replaces. It belongs in the layout, above the router outlet.
  • Assuming the canvas paints where you expect. It is position: absolute; z-index: 1 so it can lift over static page content on the way past, which means anything meant to sit ON TOP of the field needs its own stacking context. Text without relative z-10 disappears under the canvas for the length of the flight.
  • Letting the shader carry any state that could reset. Everything visible is a function of the absolute setAnimationLoop timestamp, so there is no accumulator to hand over. Drive the field from a per-frame increment instead and it will still restart on the handoff — the exact artefact the component was built to eliminate, hidden one layer deeper.

Notes

  • In Next.js App Router: mount CanvasHandoffTransition in app/layout.tsx, put a CanvasHandoffSlot in each page with an id of its own, and replace <Link> with a button calling handoff(slotId, () => router.push(href)). The component imports nothing from next/navigation — the route change is a callback, so the same host works under React Router or, as in the preview, plain useState.
  • The slot renders no children while WebGL is live. That is not cosmetic: React only reconciles children it created, so an empty child list is the guarantee that it will never touch the canvas sitting inside. Content that belongs over the field goes in a sibling, not inside the slot.
  • One context for the whole site is the real prize. Chromium allows roughly sixteen live WebGL contexts per renderer process and evicts the oldest without warning when you exceed it — a per-page canvas on a site with a persistent background is how an unrelated component three routes away turns black.
  • The field is deliberately slow, about eight seconds per visible cycle. Anything faster and the eye cannot hold a specific fold across the transition, which is the only direct evidence the viewer gets that the surface was not rebuilt.
  • Under reduced motion there is no animation loop at all — a single frame is drawn on build and again after each move — so the component costs nothing per frame for the people most likely to be on the weakest hardware.