Flying down an endless tunnel: rings rush towards you, streaks of light race past and the vanishing point leans to the cursor.
Engage
Customize
Usage
import { WarpTunnel } from "@/components/motionkit/warp-tunnel";
<WarpTunnel
className="h-[32rem] rounded-3xl"
>
<h1>Your hero</h1>
</WarpTunnel>Motion
The exact curves this component runs, plotted with Motion's own maths.
Travel
Runs on its own
Vanishing point
Pointer moves
Launch and countdown pages, gaming, sci-fi and crypto heroes, loading screens and "faster than ever" performance stories.
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.
Works in the App Router as is. Requires Tailwind CSS v4.
One command adds the file and installs its dependencies. Needs a components.json (run npx shadcn@latest init once if you don't have one).
npx shadcn@latest add https://motionkitui.com/r/warp-tunnel.jsonnpm install motionSave as components/motionkit/warp-tunnel.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>
);
}
| 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. |
How we audit: the WCAG 2.2 animation checklist and respecting reduced motion in React.
Yes. The file starts with "use client", so you can import it straight into a Server Component page or layout. It works in Vite as is, and in Astro as a React island.
Nothing beyond React and Tailwind CSS v4. There is no MotionKit package and no Tailwind config to add.
Yes. Run npx shadcn@latest add https://motionkitui.com/r/warp-tunnel.json in any project with a components.json. It saves the file to components/motionkit and installs its dependencies.
With reduced motion on, it draws one still frame. It also stops drawing off screen.
Flicker Grid
A field of tiny squares that flicker softly, drawn on a canvas so it stays light.
Cursor Trail
A soft ribbon of light behind the pointer, across the window or inside one section.
Infinite Perspective Grid
A true 3D floor of glowing lines that recedes forever to a lit horizon, with an optional ceiling for a tunnel.