Skip to content
3D

Tunnel

A tunnel is polar coordinates with the radius inverted: angle becomes the wall's horizontal axis and 1/radius becomes depth. That gives infinite perspective from two divisions — no mesh, no camera, no far plane, and no cost that scales with how far down it you can see. Fog toward the centre is what makes it read as distance; without it the pattern converges to a hard singularity at the vanishing point and looks like a glitch.

Loading preview…

Usage

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Tunnel } from "@/components/tunnel"; <Tunnel />

Customise

1
1

Rotation applied with depth. 0 is a straight tunnel.

1

The component

components/craft/3d/Tunnel.tsx

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useEffect, useRef } from "react";import { createPointerSource } from "../lib/pointerSource"; /** * An endless tunnel, built from a coordinate transform rather than geometry. * * The trick is that a tunnel is just polar coordinates with the radius * inverted: angle becomes the wall's horizontal axis, 1/radius becomes depth. * Feeding those into a repeating pattern gives infinite perspective from two * divisions — no mesh, no camera, no far plane, and no cost that scales with * how far down it you can see. * * Fog toward the centre is what makes it read as distance. Without it the * pattern converges to a hard singularity at the vanishing point, which reads * as a glitch rather than as depth. */ const VERTEX = `attribute vec2 position;void main() { gl_Position = vec4(position, 0.0, 1.0); }`; const FRAGMENT = `precision mediump float;uniform vec2  uResolution;uniform float uTime;uniform vec2  uPointer;uniform vec3  uColorA;uniform vec3  uColorB;uniform float uTwist;uniform float uRings; void main() {  vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / uResolution.y;  /* Steering: shifting the centre is all a "camera" needs to be here. */  uv -= (uPointer - 0.5) * 0.6;   float radius = length(uv);  float angle  = atan(uv.y, uv.x);   /* 1/radius IS the depth axis. Points near the centre are far away, and the     reciprocal gives exactly the foreshortening perspective would. */  float depth = 1.0 / max(radius, 0.001) + uTime;  angle += depth * uTwist * 0.08;   /* Repeating bands along both axes: rings receding, ribs around the wall. */  float rings = smoothstep(0.42, 0.5, abs(fract(depth * uRings * 0.12) - 0.5));  float ribs  = smoothstep(0.35, 0.5, abs(fract(angle * 2.2) - 0.5));  float pattern = max(rings, ribs * 0.55);   vec3 colour = mix(uColorB, uColorA, pattern);  /* Fog, so the vanishing point resolves into haze instead of a singularity. */  colour *= smoothstep(0.0, 0.42, radius);  gl_FragColor = vec4(colour, 1.0);}`; function compile(gl: WebGLRenderingContext, type: number, src: string) {  const s = gl.createShader(type);  if (!s) return null;  gl.shaderSource(s, src);  gl.compileShader(s);  return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null;} function toRgb(hex: string): [number, number, number] {  const m = /^#?([\da-f]{6})$/i.exec(hex.trim());  if (!m) return [0, 0, 0];  const n = parseInt(m[1], 16);  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];} export interface TunnelProps {  colorA?: string;  colorB?: string;  speed?: number;  /** Rotation applied with depth — 0 is a straight tunnel. */  twist?: number;  /** Ring density along the depth axis. */  rings?: number;  still?: boolean;  className?: string;} export function Tunnel({  colorA = "#c3f53c",  colorB = "#0a0b0e",  speed = 1,  twist = 1,  rings = 1,  still = false,  className = "",}: TunnelProps) {  const ref = useRef<HTMLCanvasElement>(null);   useEffect(() => {    const canvas = ref.current;    if (!canvas) return;    const gl = canvas.getContext("webgl", { alpha: false, antialias: false });    if (!gl) return;     const vs = compile(gl, gl.VERTEX_SHADER, VERTEX);    const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);    if (!vs || !fs) return;    const program = gl.createProgram();    if (!program) return;    gl.attachShader(program, vs);    gl.attachShader(program, fs);    gl.linkProgram(program);    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return;    gl.useProgram(program);     const buffer = gl.createBuffer();    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);    const position = gl.getAttribLocation(program, "position");    gl.enableVertexAttribArray(position);    gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);     const uniform = (n: string) => gl.getUniformLocation(program, n);    const uResolution = uniform("uResolution");    const uTime = uniform("uTime");    const uPointer = uniform("uPointer");    gl.uniform3fv(uniform("uColorA"), toRgb(colorA));    gl.uniform3fv(uniform("uColorB"), toRgb(colorB));    gl.uniform1f(uniform("uTwist"), twist);    gl.uniform1f(uniform("uRings"), rings);     const reduced =      still ||      (typeof matchMedia === "function" &&        matchMedia("(prefers-reduced-motion: reduce)").matches);     // Owned by this effect run: the canvas keeps its size across re-runs while    // a new program's uniforms start at zero.    let uw = 0;    let uh = 0;    const resize = () => {      const dpr = Math.min(devicePixelRatio || 1, 2);      const w = Math.floor(canvas.clientWidth * dpr);      const h = Math.floor(canvas.clientHeight * dpr);      if (!w || !h || (w === uw && h === uh)) return;      uw = w;      uh = h;      canvas.width = w;      canvas.height = h;      gl.viewport(0, 0, w, h);      gl.uniform2f(uResolution, w, h);    };     const pointer = createPointerSource(canvas);    let cx = 0.5;    let cy = 0.5;    const start = performance.now();     const draw = (now: number) => {      resize();      const elapsed = (now - start) / 1000;      const target = pointer.read(elapsed);      cx += (target.x - cx) * 0.06;      cy += (target.y - cy) * 0.06;      gl.uniform2f(uPointer, cx, cy);      gl.uniform1f(uTime, elapsed * speed);      gl.drawArrays(gl.TRIANGLES, 0, 3);    };     let frame = 0;    const loop = (now: number) => {      draw(now);      frame = requestAnimationFrame(loop);    };     draw(start);    if (!reduced) frame = requestAnimationFrame(loop);     const observer = new ResizeObserver(() => {      resize();      if (reduced) draw(start);    });    observer.observe(canvas);     return () => {      cancelAnimationFrame(frame);      observer.disconnect();      pointer.dispose();      gl.deleteProgram(program);      gl.deleteShader(vs);      gl.deleteShader(fs);      gl.deleteBuffer(buffer);    };  }, [colorA, colorB, speed, twist, rings, still]);   return <canvas ref={ref} aria-hidden className={`block h-full w-full ${className}`} />;} 

One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.

What it costs

Weight

+2.3KB gzipped · no new dependencies

Runs on

GPU

Work happens in a shader. The main thread only issues draw calls, so JavaScript stays free, but it costs GPU time and battery on mobile.

Reduced motion

Draws one frame and stops. The tunnel is fully formed, it simply does not travel.

Accessibility
  • Canvas is aria-hidden; decorative.
  • No focusable elements.
  • Strong perspective motion is a vestibular trigger, which is why the still path is a single frame rather than a slowed one.
Watch out
  • Receding motion toward a vanishing point is among the more nauseating effects on the web. Use it behind content sparingly, and never full-screen on a page people must read.
  • Needs WebGL; renders nothing without it.