# Warp Tunnel

> Flying down an endless tunnel: rings rush towards you, streaks of light race past and the vanishing point leans to the cursor.

Category: Backgrounds · Free · Updated 2026-10-23 · Live demo: https://motionkitui.com/components/warp-tunnel

## When to use it

Launch and countdown pages, gaming, sci-fi and crypto heroes, loading screens and "faster than ever" performance stories.

## How it works

Each ring has a depth that falls from 1 to 0 as you travel, and its radius is its tunnel radius divided by that depth, which is the perspective divide: far rings crowd towards the vanishing point and near ones rush outwards. Rings fade in from the distance and out as they pass you. Streaks are short lines drawn between two depths, so they stretch as they near. The pointer moves the vanishing point, and every ring and streak is offset in proportion to its depth, so the tunnel bends towards it.

## Installation

```bash
npx shadcn@latest add https://motionkitui.com/r/warp-tunnel.json
```

Or copy `components/motionkit/warp-tunnel.tsx` below. Requires react and Tailwind CSS v4.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `colors` | `[string, string]` | `["#5FAEB4", "#A56D5B"]` | Ring and streak colours. |
| `shape` | `"circle" \| "square" \| "hexagon"` | `"circle"` | Shape of the rings. |
| `speed` | `number` | `1` | Travel speed. 0 holds it still. |
| `streaks` | `number` | `90` | Streaks of light rushing past. |
| `interactive` | `boolean` | `true` | Lean the vanishing point towards the pointer. |
| `children` | `ReactNode` |  | Your content, layered above. |

## Accessibility

- **Keyboard: Not applicable.** Decorative, nothing to focus.
- **Screen readers: Pass.** The canvas is hidden from assistive tech; only your content is read.
- **Reduced motion: Pass.** With reduced motion on, it draws one still frame. It also stops drawing off screen.
- **Colour contrast: Pass.** A dark core at the vanishing point keeps ivory text at 14.2:1 or better.

## Source

```tsx
"use client";

import { useEffect, useRef, type ReactNode } from "react";

type WarpTunnelProps = {
  children?: ReactNode;
  /** Colours of the rings and the streaks. */
  colors?: [string, string];
  /** The rings' shape. */
  shape?: "circle" | "square" | "hexagon";
  /** Travel speed. 0 holds it still. */
  speed?: number;
  /** Streaks of light rushing past. */
  streaks?: number;
  /** Shift the vanishing point a little towards the pointer. */
  interactive?: boolean;
  className?: string;
};

function toRgb(hex: string): [number, number, number] {
  const value = hex.replace("#", "");
  const full = value.length === 3 ? [...value].map((c) => c + c).join("") : value;
  const number = parseInt(full, 16);
  return [(number >> 16) & 255, (number >> 8) & 255, number & 255];
}

const RINGS = 22;

/**
 * Flying down an endless tunnel: rings rush towards you and grow, streaks of light race past,
 * and the vanishing point leans towards the pointer. Drawn on a canvas, paused off screen.
 */
export function WarpTunnel({ children, colors = ["#5FAEB4", "#A56D5B"], shape = "circle", speed = 1, streaks = 90, interactive = true, className = "" }: WarpTunnelProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    const context = canvas?.getContext("2d");
    const area = canvas?.parentElement;
    if (!canvas || !context || !area) return;
    const [ring, streak] = colors.map(toRgb);
    const still = speed === 0 || window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    let width = 0;
    let height = 0;
    let frame = 0;
    let visible = true;
    let last = 0;
    let travel = 0;
    const centre = { x: 0, y: 0, tx: 0, ty: 0 };
    // streaks live in tunnel space: an angle, a distance from the axis and a depth
    const lights = Array.from({ length: streaks }, (_, index) => ({ angle: ((index * 137.508) % 360) * (Math.PI / 180), offset: 0.35 + ((index * 7919) % 100) / 160, z: ((index * 104729) % 1000) / 1000 }));

    function resize() {
      const rect = canvas!.getBoundingClientRect();
      const ratio = Math.min(window.devicePixelRatio || 1, 2);
      width = rect.width;
      height = rect.height;
      canvas!.width = width * ratio;
      canvas!.height = height * ratio;
      context!.setTransform(ratio, 0, 0, ratio, 0, 0);
    }

    function outline(x: number, y: number, radius: number, turn: number) {
      context!.beginPath();
      if (shape === "circle") return context!.arc(x, y, radius, 0, Math.PI * 2);
      const sides = shape === "square" ? 4 : 6;
      for (let side = 0; side <= sides; side++) {
        const angle = turn + (side / sides) * Math.PI * 2 + (shape === "square" ? Math.PI / 4 : 0);
        const px = x + Math.cos(angle) * radius;
        const py = y + Math.sin(angle) * radius;
        if (side === 0) context!.moveTo(px, py);
        else context!.lineTo(px, py);
      }
    }

    function draw(time: number) {
      const delta = last ? Math.min(0.05, (time - last) / 1000) : 0;
      last = time;
      travel += delta * speed * 0.18;
      centre.x += (centre.tx - centre.x) * 0.05;
      centre.y += (centre.ty - centre.y) * 0.05;
      context!.clearRect(0, 0, width, height);
      const size = Math.hypot(width, height) * 0.5;
      context!.globalCompositeOperation = "lighter";

      // depth z runs from 1 (far) to 0 (here); a ring's radius is its tunnel radius divided by depth
      for (let index = 0; index < RINGS; index++) {
        const z = 1 - ((index / RINGS + travel) % 1);
        const radius = (size * 0.12) / Math.max(z, 0.02);
        if (radius > size * 2.4) continue;
        const fade = Math.min(1, (1 - z) * 2.2) * Math.min(1, z * 4);
        // nearer rings sit further from the vanishing point, so the tunnel bends when the centre moves
        const x = width / 2 + centre.x * z;
        const y = height / 2 + centre.y * z;
        context!.lineWidth = 0.6 + (1 - z) * 1.4;
        context!.strokeStyle = `rgb(${ring[0]} ${ring[1]} ${ring[2]} / ${0.32 * fade})`;
        outline(x, y, radius, travel * 0.6 + z * 0.8);
        context!.stroke();
      }

      for (const light of lights) {
        light.z -= delta * speed * 0.5 * (0.6 + light.offset * 0.4);
        if (light.z <= 0.03) light.z += 1;
        const near = Math.max(light.z, 0.03);
        const far = Math.min(1, near + 0.05 + 0.04 * speed);
        const reach = size * 0.12 * light.offset;
        const fade = Math.min(1, (1 - near) * 1.6);
        context!.strokeStyle = `rgb(${streak[0]} ${streak[1]} ${streak[2]} / ${0.55 * fade})`;
        context!.lineWidth = 0.5 + (1 - near) * 1.2;
        context!.beginPath();
        context!.moveTo(width / 2 + centre.x * far + Math.cos(light.angle) * (reach / far), height / 2 + centre.y * far + Math.sin(light.angle) * (reach / far));
        context!.lineTo(width / 2 + centre.x * near + Math.cos(light.angle) * (reach / near), height / 2 + centre.y * near + Math.sin(light.angle) * (reach / near));
        context!.stroke();
      }
      context!.globalCompositeOperation = "source-over";
    }

    function loop(time: number) {
      draw(time);
      frame = visible ? requestAnimationFrame(loop) : 0;
      if (!frame) last = 0;
    }

    resize();
    draw(0);
    if (!still) frame = requestAnimationFrame(loop);
    const resizeObserver = new ResizeObserver(() => {
      resize();
      draw(performance.now());
    });
    resizeObserver.observe(canvas);
    const visibilityObserver = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      if (visible && !frame && !still) frame = requestAnimationFrame(loop);
    });
    visibilityObserver.observe(canvas);

    function move(event: PointerEvent) {
      if (!interactive || still || event.pointerType === "touch") return;
      const rect = canvas!.getBoundingClientRect();
      centre.tx = (event.clientX - rect.left - rect.width / 2) * 0.35;
      centre.ty = (event.clientY - rect.top - rect.height / 2) * 0.35;
    }
    const leave = () => {
      centre.tx = 0;
      centre.ty = 0;
    };
    area.addEventListener("pointermove", move);
    area.addEventListener("pointerleave", leave);

    return () => {
      cancelAnimationFrame(frame);
      resizeObserver.disconnect();
      visibilityObserver.disconnect();
      area.removeEventListener("pointermove", move);
      area.removeEventListener("pointerleave", leave);
    };
    // the colours are compared by value, so a new array with the same colours doesn't restart it
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [colors.join(), shape, speed, streaks, interactive]);

  return (
    <div className={`relative isolate overflow-hidden bg-[#0C1010] ${className}`}>
      <canvas ref={canvasRef} aria-hidden className="absolute inset-0 -z-10 size-full" />
      <div aria-hidden className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(ellipse_40%_35%_at_center,rgb(12_16_16/0.85),transparent)]" />
      {children}
    </div>
  );
}
```
