Skip to content
Kinetic
TransitionWebGL4 files

Exit Warp Before Navigate

Choosing a destination does not navigate. It plays an exit: the DOM chrome lifts and fades, the ring of cards is shoved thirty slots forward while its vertical spacing explodes and its radius collapses, the planes dissolve in index order, and only after one settle frame does the router move. The ease is expo.in, so almost nothing happens for the first half-second and then everything happens at once.

threegsappage-exitringrouter

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/exit-warp-navigation.json
pulls inthree@^0.185.1gsap@^3.15.0
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: Exit Warp Before Navigate

Choosing a destination does not navigate. It plays an exit: the DOM chrome lifts and fades, the ring of cards is shoved thirty slots forward while its vertical spacing explodes and its radius collapses, the planes dissolve in index order, and only after one settle frame does the router move. The ease is expo.in, so almost nothing happens for the first half-second and then everything happens at once.

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

A ring of cards tears itself apart and rushes past the camera, and only then does the route change.

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

## How it works

1. Four scalars describe the ring — `position`, `spacing`, `hSpacing`, `radius` — and one `layout()` reads them: `angle = wrappedOffset(i, position, count) * (2*PI/count) * hSpacing`, then `mesh.position.set(sin(angle) * radius, offset * spacing * 0.12, cos(angle) * radius - radius)` and `mesh.rotation.y = angle`. Idle and exit both drive the same function, so no second code path can disagree about where a card belongs.
2. Idle rotation is `position += dt * 0.16` inside `setAnimationLoop`, with `dt` in seconds clamped to 50 ms — a per-frame constant would spin twice as fast on a 120 Hz display.
3. On select, a timeline fades the chrome (`opacity: 0, y: -20, 0.5s, power2.out, stagger 0.05`) and a `tl.call` pinned at an absolute 0.4s starts the warp, so the ring begins moving 0.1s before the text has finished leaving.
4. The warp is one `gsap.to` on the scalar object: `position + 30`, `spacing 0.8 -> 10`, `hSpacing 1 -> 3`, `radius 2.3 -> 0.6`, 1.4s, `expo.in`, with `onUpdate` calling `layout()`. Radius shrinking while spacing explodes is what makes the planes leave in different directions instead of receding as a group.
5. Each material tweens `opacity: 0` over 1s with `delay: 0.03 * i`, so the ring dissolves in index order rather than as a single fade.
6. The tween's `onComplete` sets a 50 ms timeout, and only that timeout calls `onNavigate`. Textures are canvas-drawn gradients with the item label baked in, so the component ships without a single image.

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

- `duration = 1.4`
  Seconds of warp. With expo.in the perceived length is roughly half the real one; below ~1.1s the accelerating half is too brief to register and it reads as a cut, above ~1.8s the destination feels withheld.
- `shove = 30`
  Ring slots travelled during the exit. For a six-item ring that is five full revolutions — enough that individual cards stop being trackable and the ring reads as a blur rather than as a carousel scrubbing.
- `WARP_CUE = 0.4`
  Absolute seconds at which the scene warp starts, against a 0.5s chrome fade. The 0.1s overlap fuses two moves into one gesture; at 0.5 they read as sequential steps, and at 0 the ring pulls the eye off the text before it has gone.
- `FADE_STAGGER = 0.03`
  Seconds between per-card dissolves. 30 ms x 6 cards is 180 ms of sweep — visible as a direction, short enough that the last card still fades inside the warp.
- `SETTLE_MS = 50`
  GSAP fires onComplete on the same frame the tween ends, before that frame is presented. Navigating there shows one frame of the un-warped ring. One extra tick guarantees the final layout has been painted.
- `WARP_SPACING / WARP_RADIUS = 10 / 0.6`
  Vertical gap goes 0.8 -> 10 while radius goes 2.3 -> 0.6. Spreading without shrinking looks like a zoom out; shrinking without spreading looks like a dolly. Both together are the only combination that reads as the ring tearing apart around the camera.
- `IDLE_SPEED = 0.16`
  Ring slots per second at rest. One card takes about six seconds to reach the front — slow enough to read as ambience, fast enough that the ring is never mistaken for a static image.

## API

```ts
function ExitWarpNavigation(props: { items: { id: string; label: string }[]; onNavigate: (id: string) => void; children?: ReactNode; duration?: number; shove?: number; className?: string }): JSX.Element
```

- `items: { id: string; label: string }[]`
  Destinations. One card and one nav button each; the label is drawn into the card texture.
- `onNavigate: (id: string) => void`
  Called after the warp and its settle frame. Do the router push here.
- `children: ReactNode`
  Chrome over the canvas. Descendants marked `data-warp-chrome` fade and lift first.
- `duration: number` — default `1.4`
  Warp length in seconds.
- `shove: number` — default `30`
  Ring slots the drum travels during the warp.
- `className: string`
  Merged onto the positioned wrapper.

## 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
useEffect(() => {
  const el = host.current;
  if (!el) return;

  // Checked in an effect, never during render: `hasWebGL()` needs `document`
  // and returning different markup on the server than on the client is a
  // hydration mismatch, not a graceful degradation.
  if (!hasWebGL() || reduced) {
    setFallback(true);
    return;
  }
  setFallback(false);

  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
  renderer.setPixelRatio(pixelRatio(2));
  renderer.setClearAlpha(0);
  el.appendChild(renderer.domElement);
  renderer.domElement.style.cssText = 'display:block;width:100%;height:100%';

  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 60);

  const geometry = new THREE.PlaneGeometry(1, CARD_H / CARD_W);
  const meshes: THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial>[] = [];
  const textures: THREE.CanvasTexture[] = [];

  items.forEach((item, i) => {
    const texture = new THREE.CanvasTexture(drawCard(item.label, i, items.length));
    texture.colorSpace = THREE.SRGBColorSpace;
    textures.push(texture);
    const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true });
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);
    meshes.push(mesh);
  });

  const state: Warp = {
    position: 0,
    spacing: REST_SPACING,
    hSpacing: 1,
    radius: REST_RADIUS,
  };

  /* One layout function, driven by four scalars. The idle loop and the exit
     tween both call it; there is no second code path that could disagree
     about where a card belongs. */
  const layout = () => {
    const count = meshes.length;
    const step = count > 0 ? (Math.PI * 2) / count : 0;
    for (let i = 0; i < count; i += 1) {
      const mesh = meshes[i];
      if (!mesh) continue;
      const offset = wrappedOffset(i, state.position, count);
      const angle = offset * step * state.hSpacing;
      mesh.position.set(
        Math.sin(angle) * state.radius,
        offset * state.spacing * Y_PER_SLOT,
        Math.cos(angle) * state.radius - state.radius,
      );
      mesh.rotation.y = angle;
    }
  };

  const resize = () => {
    const w = el.clientWidth;
    const h = el.clientHeight;
    if (w === 0 || h === 0) return;
    renderer.setSize(w, h, false);
    camera.aspect = w / h;
    // Pull the camera back on narrow boxes so the ring never grows wider
    // than the frame; a fixed z crops the front card on a phone.
    camera.position.z = 3.2 * Math.max(1, 1.35 / camera.aspect);
    camera.updateProjectionMatrix();
  };

  const observer = new ResizeObserver(resize);
  observer.observe(el);
  resize();
  layout();

  let last = 0;
  renderer.setAnimationLoop((time) => {
    const dt = last === 0 ? 0 : Math.min((time - last) / 1000, 0.05);
    last = time;
    const b = bundle.current;
    if (b && !b.exiting) {
      // Seconds, not frames: a per-frame increment idles twice as fast on a
      // 120 Hz display as it does on 60 Hz.
      state.position += dt * IDLE_SPEED;
      layout();
    }
    renderer.render(scene, camera);
  });

  bundle.current = { renderer, scene, camera, geometry, meshes, textures, state, exiting: false };

  return () => {
    observer.disconnect();
    gsap.killTweensOf(state);
    for (const mesh of meshes) gsap.killTweensOf(mesh.material);
    // disposeRenderer stops the animation loop first: a queued frame that
    // renders into a context we just released throws out of cleanup.
    disposeRenderer(renderer);
    for (const mesh of meshes) mesh.material.dispose();
    for (const texture of textures) texture.dispose();
    geometry.dispose();
    renderer.domElement.remove();
    bundle.current = null;
  };
  // `ids` stands in for `items` so a caller passing a fresh array literal
  // every render does not rebuild the whole scene each time.
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ids, reduced]);

const start = useCallback(
  (id: string) => {
    const b = bundle.current;
    const chromeEl = chrome.current;

    // Re-entrancy guard. Without it a double click starts two warps that
    // tween the same scalars in opposite directions and the ring jitters.
    if (b?.exiting) return;

    const chromeTargets = chromeEl
      ? Array.from(chromeEl.querySelectorAll<HTMLElement>('[data-warp-chrome]'))
      : [];

    if (!b) {
      // No GL: the static crossfade. Same shape, no warp.
      const target = chromeTargets.length > 0 ? chromeTargets : chromeEl;
      if (target) {
        gsap.to(target, {
          opacity: 0,
          duration: reduced ? 0.15 : 0.3,
          ease: 'power2.out',
          onComplete: () => navigate.current(id),
        });
      } else {
        navigate.current(id);
      }
      return;
    }

    b.exiting = true;
    const state = b.state;

    const tl = gsap.timeline();
    if (chromeTargets.length > 0) {
      tl.to(chromeTargets, {
        opacity: 0,
        y: -20,
        duration: CHROME_DURATION,
        ease: 'power2.out',
        stagger: CHROME_STAGGER,
      });
    }

    // Absolute position: the warp starts 0.1 s before the chrome finishes
    // leaving, so the two overlap into one gesture instead of reading as
    // "text goes, then something else happens".
    tl.call(
      () => {
        gsap.to(state, {
          position: state.position + shove,
          spacing: WARP_SPACING,
          hSpacing: WARP_HSPACING,
          radius: WARP_RADIUS,
          duration,
          ease: 'expo.in',
          onComplete: () => {
            // One more frame before the route changes. GSAP's onComplete
            // fires on the same frame the tween ends, so navigating here
            // shows a single frame of the un-warped ring.
            const t = setTimeout(() => navigate.current(id), SETTLE_MS);
            timers.current.push(t);
          },
        });
        for (let i = 0; i < b.meshes.length; i += 1) {
          const mesh = b.meshes[i];
          if (!mesh) continue;
          gsap.to(mesh.material, {
            opacity: 0,
            duration: 1,
            ease: 'power2.in',
            delay: FADE_STAGGER * i,
          });
        }
      },
      undefined,
      WARP_CUE,
    );
  },
  [duration, shove, reduced],
);
```

## Requirements

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

**Cleanup.** Disconnects the ResizeObserver, kills the tweens on the scalar object and on every material, clears the pending navigation timeout, calls `disposeRenderer` (which stops the animation loop, disposes, forces context loss and zeroes the canvas), then disposes every material, texture and the shared geometry, and removes the canvas element.

**Reduced motion.** No WebGL context is created. The same cards render as a static CSS-gradient grid, and choosing one fades the chrome over 0.15s before navigating — the transition still covers the swap, it just does not move.

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

- Calling `router.push` in the click handler and animating afterwards. React unmounts the canvas as the route resolves, so the warp plays for two frames and then disappears; the navigation must be the last step, inside the tween's completion.
- No re-entrancy guard. A double click starts two warps tweening the same four scalars from different origins — the ring jitters and the second navigation fires into an already-unmounting tree.
- Navigating in `onComplete` instead of one tick later. The final frame has not been presented, so a single frame of the intact ring flashes just before the route changes.
- Calling `renderer.dispose()` and stopping there. The GL context stays alive; Chromium kills the oldest context once about sixteen exist, so an unrelated canvas elsewhere on the page goes black. `disposeRenderer` also calls `forceContextLoss()` and zeroes the backing store.
- Leaving the exit tweens running through unmount. They keep writing to disposed materials and to a scalar object nobody reads; the material writes throw once the context is gone. Cleanup kills the tweens on the state object and every material before disposing.
- Deciding whether WebGL exists during render. `hasWebGL()` needs `document`, so it returns false on the server and true on the client, and the two renders disagree — the check runs in an effect and flips to the static fallback afterwards.
- Advancing the idle rotation by a per-frame constant. The ring idles at double speed on a 120 Hz laptop, which is exactly the sort of bug that never reproduces on the developer's machine.

## Notes

- App Router wiring: `onNavigate={(id) => router.push(`/work/${id}`)}`. Call `router.prefetch` on hover (see `hover-prefetch-wipe`) so the 1.4s warp is spent downloading the destination rather than waiting after it.
- Reduced motion and no-WebGL share one path: a static grid of the same cards, a 0.3s fade of the chrome, then navigate. No context is ever created in that branch.
- The ring is index-driven, not raycast-driven. Picking is done with real DOM buttons over the canvas, which keeps the nav keyboard-reachable and screen-reader-legible for free.

## Definition of done

The ring idles at the same speed on a 60 Hz and a 120 Hz display. Choosing a destination fades the caption, then the ring barely moves for about half a second before tearing outward and past the camera; the new route appears only once the frame is empty, with no flash of the intact ring. Unmount mid-warp and the console stays clean.

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

Source

4 files, exactly as installed.

installs tocomponents/motion/exit-warp-navigation.tsx
'use client';

import gsap from 'gsap';
import { useCallback, useEffect, useRef, useState, type ReactNode } 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 { wrappedOffset } from '@/lib/wrap';

/* ==========================================================================
   Exit Warp Before Navigate
   --------------------------------------------------------------------------
   A ring of cards idles in front of the camera. Choosing one does not
   navigate — it plays a four-stage exit and navigates at the end of it:

     1. DOM chrome fades and lifts        (0.5 s, power2.out, 0.05 stagger)
     2. 0.1 s before that ends, the ring warps  (1.4 s, expo.in)
     3. one settle frame                  (50 ms)
     4. the route actually changes

   `expo.in` is the whole reason it reads as falling into the destination
   rather than as a cut: the first ~700 ms barely move, so your eye stays on
   the card you picked while the caption leaves, and the last ~400 ms cover
   almost the entire distance. The acceleration IS the transition.
   ========================================================================== */

export interface WarpItem {
  id: string;
  label: string;
}

export interface ExitWarpNavigationProps {
  items: WarpItem[];
  /** Called after the warp finishes. Do the `router.push` here. */
  onNavigate: (id: string) => void;
  /** Chrome drawn over the canvas. Descendants marked `data-warp-chrome` leave first. */
  children?: ReactNode;
  /** Warp length in seconds. */
  duration?: number;
  /** How many ring slots the drum is shoved forward during the warp. */
  shove?: number;
  className?: string;
}

const CHROME_DURATION = 0.5;
const CHROME_STAGGER = 0.05;
const WARP_CUE = 0.4;
const FADE_STAGGER = 0.03;
const SETTLE_MS = 50;

const IDLE_SPEED = 0.16;
const REST_RADIUS = 2.3;
const REST_SPACING = 0.8;
const WARP_SPACING = 10;
const WARP_HSPACING = 3;
const WARP_RADIUS = 0.6;
const Y_PER_SLOT = 0.12;

const CARD_W = 320;
const CARD_H = 200;

/* --------------------------------------------------------------------------
   Card artwork, drawn at runtime. No image ships with this component and none
   is fetched: the texture is a canvas gradient plus the item's own label, so
   the ring always has as many distinct faces as the caller has routes.
   -------------------------------------------------------------------------- */
function drawCard(label: string, index: number, count: number): HTMLCanvasElement {
  const canvas = document.createElement('canvas');
  canvas.width = CARD_W;
  canvas.height = CARD_H;
  const ctx = canvas.getContext('2d');
  if (!ctx) return canvas;

  const hue = (index / Math.max(count, 1)) * 300 + 200;
  const grad = ctx.createLinearGradient(0, 0, CARD_W, CARD_H);
  grad.addColorStop(0, `hsl(${hue % 360} 45% 22%)`);
  grad.addColorStop(1, `hsl(${(hue + 40) % 360} 35% 8%)`);
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, CARD_W, CARD_H);

  ctx.strokeStyle = 'rgba(255,255,255,0.18)';
  ctx.lineWidth = 2;
  ctx.strokeRect(1, 1, CARD_W - 2, CARD_H - 2);

  ctx.fillStyle = 'rgba(255,255,255,0.55)';
  ctx.font = '500 18px ui-monospace, monospace';
  ctx.fillText(String(index + 1).padStart(2, '0'), 20, 36);

  ctx.fillStyle = 'rgba(255,255,255,0.92)';
  ctx.font = '500 26px ui-monospace, monospace';
  ctx.fillText(label.slice(0, 16), 20, CARD_H - 26);

  return canvas;
}

interface Warp {
  position: number;
  spacing: number;
  hSpacing: number;
  radius: number;
}

interface Bundle {
  renderer: THREE.WebGLRenderer;
  scene: THREE.Scene;
  camera: THREE.PerspectiveCamera;
  geometry: THREE.PlaneGeometry;
  meshes: THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial>[];
  textures: THREE.CanvasTexture[];
  state: Warp;
  exiting: boolean;
}

export function ExitWarpNavigation({
  items,
  onNavigate,
  children,
  duration = 1.4,
  shove = 30,
  className,
}: ExitWarpNavigationProps) {
  const host = useRef<HTMLDivElement>(null);
  const chrome = useRef<HTMLDivElement>(null);
  const bundle = useRef<Bundle | null>(null);
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
  const [fallback, setFallback] = useState(false);
  const reduced = useReducedMotion();

  const navigate = useRef(onNavigate);
  navigate.current = onNavigate;

  const ids = items.map((i) => i.id).join('|');

  // #region critical
  useEffect(() => {
    const el = host.current;
    if (!el) return;

    // Checked in an effect, never during render: `hasWebGL()` needs `document`
    // and returning different markup on the server than on the client is a
    // hydration mismatch, not a graceful degradation.
    if (!hasWebGL() || reduced) {
      setFallback(true);
      return;
    }
    setFallback(false);

    const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    renderer.setPixelRatio(pixelRatio(2));
    renderer.setClearAlpha(0);
    el.appendChild(renderer.domElement);
    renderer.domElement.style.cssText = 'display:block;width:100%;height:100%';

    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 60);

    const geometry = new THREE.PlaneGeometry(1, CARD_H / CARD_W);
    const meshes: THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial>[] = [];
    const textures: THREE.CanvasTexture[] = [];

    items.forEach((item, i) => {
      const texture = new THREE.CanvasTexture(drawCard(item.label, i, items.length));
      texture.colorSpace = THREE.SRGBColorSpace;
      textures.push(texture);
      const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true });
      const mesh = new THREE.Mesh(geometry, material);
      scene.add(mesh);
      meshes.push(mesh);
    });

    const state: Warp = {
      position: 0,
      spacing: REST_SPACING,
      hSpacing: 1,
      radius: REST_RADIUS,
    };

    /* One layout function, driven by four scalars. The idle loop and the exit
       tween both call it; there is no second code path that could disagree
       about where a card belongs. */
    const layout = () => {
      const count = meshes.length;
      const step = count > 0 ? (Math.PI * 2) / count : 0;
      for (let i = 0; i < count; i += 1) {
        const mesh = meshes[i];
        if (!mesh) continue;
        const offset = wrappedOffset(i, state.position, count);
        const angle = offset * step * state.hSpacing;
        mesh.position.set(
          Math.sin(angle) * state.radius,
          offset * state.spacing * Y_PER_SLOT,
          Math.cos(angle) * state.radius - state.radius,
        );
        mesh.rotation.y = angle;
      }
    };

    const resize = () => {
      const w = el.clientWidth;
      const h = el.clientHeight;
      if (w === 0 || h === 0) return;
      renderer.setSize(w, h, false);
      camera.aspect = w / h;
      // Pull the camera back on narrow boxes so the ring never grows wider
      // than the frame; a fixed z crops the front card on a phone.
      camera.position.z = 3.2 * Math.max(1, 1.35 / camera.aspect);
      camera.updateProjectionMatrix();
    };

    const observer = new ResizeObserver(resize);
    observer.observe(el);
    resize();
    layout();

    let last = 0;
    renderer.setAnimationLoop((time) => {
      const dt = last === 0 ? 0 : Math.min((time - last) / 1000, 0.05);
      last = time;
      const b = bundle.current;
      if (b && !b.exiting) {
        // Seconds, not frames: a per-frame increment idles twice as fast on a
        // 120 Hz display as it does on 60 Hz.
        state.position += dt * IDLE_SPEED;
        layout();
      }
      renderer.render(scene, camera);
    });

    bundle.current = { renderer, scene, camera, geometry, meshes, textures, state, exiting: false };

    return () => {
      observer.disconnect();
      gsap.killTweensOf(state);
      for (const mesh of meshes) gsap.killTweensOf(mesh.material);
      // disposeRenderer stops the animation loop first: a queued frame that
      // renders into a context we just released throws out of cleanup.
      disposeRenderer(renderer);
      for (const mesh of meshes) mesh.material.dispose();
      for (const texture of textures) texture.dispose();
      geometry.dispose();
      renderer.domElement.remove();
      bundle.current = null;
    };
    // `ids` stands in for `items` so a caller passing a fresh array literal
    // every render does not rebuild the whole scene each time.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [ids, reduced]);

  const start = useCallback(
    (id: string) => {
      const b = bundle.current;
      const chromeEl = chrome.current;

      // Re-entrancy guard. Without it a double click starts two warps that
      // tween the same scalars in opposite directions and the ring jitters.
      if (b?.exiting) return;

      const chromeTargets = chromeEl
        ? Array.from(chromeEl.querySelectorAll<HTMLElement>('[data-warp-chrome]'))
        : [];

      if (!b) {
        // No GL: the static crossfade. Same shape, no warp.
        const target = chromeTargets.length > 0 ? chromeTargets : chromeEl;
        if (target) {
          gsap.to(target, {
            opacity: 0,
            duration: reduced ? 0.15 : 0.3,
            ease: 'power2.out',
            onComplete: () => navigate.current(id),
          });
        } else {
          navigate.current(id);
        }
        return;
      }

      b.exiting = true;
      const state = b.state;

      const tl = gsap.timeline();
      if (chromeTargets.length > 0) {
        tl.to(chromeTargets, {
          opacity: 0,
          y: -20,
          duration: CHROME_DURATION,
          ease: 'power2.out',
          stagger: CHROME_STAGGER,
        });
      }

      // Absolute position: the warp starts 0.1 s before the chrome finishes
      // leaving, so the two overlap into one gesture instead of reading as
      // "text goes, then something else happens".
      tl.call(
        () => {
          gsap.to(state, {
            position: state.position + shove,
            spacing: WARP_SPACING,
            hSpacing: WARP_HSPACING,
            radius: WARP_RADIUS,
            duration,
            ease: 'expo.in',
            onComplete: () => {
              // One more frame before the route changes. GSAP's onComplete
              // fires on the same frame the tween ends, so navigating here
              // shows a single frame of the un-warped ring.
              const t = setTimeout(() => navigate.current(id), SETTLE_MS);
              timers.current.push(t);
            },
          });
          for (let i = 0; i < b.meshes.length; i += 1) {
            const mesh = b.meshes[i];
            if (!mesh) continue;
            gsap.to(mesh.material, {
              opacity: 0,
              duration: 1,
              ease: 'power2.in',
              delay: FADE_STAGGER * i,
            });
          }
        },
        undefined,
        WARP_CUE,
      );
    },
    [duration, shove, reduced],
  );
  // #endregion critical

  useEffect(
    () => () => {
      for (const t of timers.current) clearTimeout(t);
      timers.current = [];
    },
    [],
  );

  return (
    <div className={cn('relative h-full w-full overflow-hidden', className)}>
      <div ref={host} className="absolute inset-0" aria-hidden />

      {fallback ? (
        <div className="absolute inset-0 grid grid-cols-2 content-center gap-2 p-4 sm:grid-cols-3">
          {items.map((item, i) => (
            <div
              key={item.id}
              className="flex aspect-[16/10] flex-col justify-end rounded-md border border-line bg-linear-to-br from-surface to-bg p-2"
            >
              <span className="meta text-faint">{String(i + 1).padStart(2, '0')}</span>
              <span className="meta text-fg">{item.label}</span>
            </div>
          ))}
        </div>
      ) : null}

      <div ref={chrome} className="absolute inset-0 flex flex-col justify-between p-4">
        {children}
        <div className="flex flex-wrap gap-x-4 gap-y-1" data-warp-chrome>
          {items.map((item) => (
            <button
              key={item.id}
              type="button"
              onClick={() => start(item.id)}
              className="meta transition-colors hover:text-accent"
            >
              {item.label}
            </button>
          ))}
        </div>
      </div>
    </div>
  );
}
05

API

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

function ExitWarpNavigation(props: { items: { id: string; label: string }[]; onNavigate: (id: string) => void; children?: ReactNode; duration?: number; shove?: number; className?: string }): JSX.Element
Parameters
items{ id: string; label: string }[]Destinations. One card and one nav button each; the label is drawn into the card texture.
onNavigate(id: string) => voidCalled after the warp and its settle frame. Do the router push here.
childrenReactNodeChrome over the canvas. Descendants marked data-warp-chrome fade and lift first.
durationnumber = 1.4Warp length in seconds.
shovenumber = 30Ring slots the drum travels during the warp.
classNamestringMerged onto the positioned wrapper.
06

How it works

The technique in order, not the code in order.

  1. 01

    Four scalars describe the ring — position, spacing, hSpacing, radius — and one layout() reads them: angle = wrappedOffset(i, position, count) * (2*PI/count) * hSpacing, then mesh.position.set(sin(angle) * radius, offset * spacing * 0.12, cos(angle) * radius - radius) and mesh.rotation.y = angle. Idle and exit both drive the same function, so no second code path can disagree about where a card belongs.

  2. 02

    Idle rotation is position += dt * 0.16 inside setAnimationLoop, with dt in seconds clamped to 50 ms — a per-frame constant would spin twice as fast on a 120 Hz display.

  3. 03

    On select, a timeline fades the chrome (opacity: 0, y: -20, 0.5s, power2.out, stagger 0.05) and a tl.call pinned at an absolute 0.4s starts the warp, so the ring begins moving 0.1s before the text has finished leaving.

  4. 04

    The warp is one gsap.to on the scalar object: position + 30, spacing 0.8 -> 10, hSpacing 1 -> 3, radius 2.3 -> 0.6, 1.4s, expo.in, with onUpdate calling layout(). Radius shrinking while spacing explodes is what makes the planes leave in different directions instead of receding as a group.

  5. 05

    Each material tweens opacity: 0 over 1s with delay: 0.03 * i, so the ring dissolves in index order rather than as a single fade.

  6. 06

    The tween's onComplete sets a 50 ms timeout, and only that timeout calls onNavigate. Textures are canvas-drawn gradients with the item label baked in, so the component ships without a single image.

Load-bearing constants

Load-bearing constants
duration = 1.4Seconds of warp. With expo.in the perceived length is roughly half the real one; below ~1.1s the accelerating half is too brief to register and it reads as a cut, above ~1.8s the destination feels withheld.
shove = 30Ring slots travelled during the exit. For a six-item ring that is five full revolutions — enough that individual cards stop being trackable and the ring reads as a blur rather than as a carousel scrubbing.
WARP_CUE = 0.4Absolute seconds at which the scene warp starts, against a 0.5s chrome fade. The 0.1s overlap fuses two moves into one gesture; at 0.5 they read as sequential steps, and at 0 the ring pulls the eye off the text before it has gone.
FADE_STAGGER = 0.03Seconds between per-card dissolves. 30 ms x 6 cards is 180 ms of sweep — visible as a direction, short enough that the last card still fades inside the warp.
SETTLE_MS = 50GSAP fires onComplete on the same frame the tween ends, before that frame is presented. Navigating there shows one frame of the un-warped ring. One extra tick guarantees the final layout has been painted.
WARP_SPACING / WARP_RADIUS = 10 / 0.6Vertical gap goes 0.8 -> 10 while radius goes 2.3 -> 0.6. Spreading without shrinking looks like a zoom out; shrinking without spreading looks like a dolly. Both together are the only combination that reads as the ring tearing apart around the camera.
IDLE_SPEED = 0.16Ring slots per second at rest. One card takes about six seconds to reach the front — slow enough to read as ambience, fast enough that the ring is never mistaken for a static image.
07

Behaviour

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

Reduced motion

No WebGL context is created. The same cards render as a static CSS-gradient grid, and choosing one fades the chrome over 0.15s before navigating — the transition still covers the swap, it just does not move.

Cleanup

Disconnects the ResizeObserver, kills the tweens on the scalar object and on every material, clears the pending navigation timeout, calls disposeRenderer (which stops the animation loop, disposes, forces context loss and zeroes the canvas), then disposes every material, texture and the shared geometry, and removes the canvas element.

Definition of done

The ring idles at the same speed on a 60 Hz and a 120 Hz display. Choosing a destination fades the caption, then the ring barely moves for about half a second before tearing outward and past the camera; the new route appears only once the frame is empty, with no flash of the intact ring. Unmount mid-warp and the console stays clean.

Pitfalls

  • Calling router.push in the click handler and animating afterwards. React unmounts the canvas as the route resolves, so the warp plays for two frames and then disappears; the navigation must be the last step, inside the tween's completion.
  • No re-entrancy guard. A double click starts two warps tweening the same four scalars from different origins — the ring jitters and the second navigation fires into an already-unmounting tree.
  • Navigating in onComplete instead of one tick later. The final frame has not been presented, so a single frame of the intact ring flashes just before the route changes.
  • Calling renderer.dispose() and stopping there. The GL context stays alive; Chromium kills the oldest context once about sixteen exist, so an unrelated canvas elsewhere on the page goes black. disposeRenderer also calls forceContextLoss() and zeroes the backing store.
  • Leaving the exit tweens running through unmount. They keep writing to disposed materials and to a scalar object nobody reads; the material writes throw once the context is gone. Cleanup kills the tweens on the state object and every material before disposing.
  • Deciding whether WebGL exists during render. hasWebGL() needs document, so it returns false on the server and true on the client, and the two renders disagree — the check runs in an effect and flips to the static fallback afterwards.
  • Advancing the idle rotation by a per-frame constant. The ring idles at double speed on a 120 Hz laptop, which is exactly the sort of bug that never reproduces on the developer's machine.

Notes

  • App Router wiring: onNavigate={(id) => router.push(/work/${id})}. Call router.prefetch on hover (see hover-prefetch-wipe) so the 1.4s warp is spent downloading the destination rather than waiting after it.
  • Reduced motion and no-WebGL share one path: a static grid of the same cards, a 0.3s fade of the chrome, then navigate. No context is ever created in that branch.
  • The ring is index-driven, not raycast-driven. Picking is done with real DOM buttons over the canvas, which keeps the nav keyboard-reachable and screen-reader-legible for free.