3D & Experimental
InfiniteTunnel
A night drive through a road tunnel that never ends: tiled walls, lamp strips, a wet road that mirrors them, tail lights ahead. The mouse looks around; hold a press, Space or Enter to speed up. WebGL, no three.js.
Download
- React
- Tailwind CSS v4
- motion
components/ui/infinite-tunnel.tsx
"use client";
import { useEffect, useRef } from "react";
import { useReducedMotion } from "motion/react";
type InfiniteTunnelProps = {
/** Six-digit hex colour of the tunnel lamps. */
lamp?: string;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
/** Name read out by screen readers. */
label?: string;
/** Set the size here. */
className?: string;
};
const MAX_PIXELS = 900000;
const VERTEX = `attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}`;
const FRAGMENT = `precision highp float;
uniform vec2 res,look;
uniform vec3 lampC;
uniform float z0,time,rush;
const float GAP=3.2,LANE=-.68;
vec2 path(float z){return vec2(sin(z*.085)*2.2+sin(z*.037+1.)*1.4,sin(z*.06)*.35);}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
float tube(vec3 p){
vec2 q=p.xy-path(p.z);
return min((1.85-length(q*vec2(.78,1.)-vec2(0.,.35)))*.8,q.y+1.);
}
float march(vec3 o,vec3 d,int count){
float t=.05;
for(int i=0;i<44;i++){
if(i>=count)break;
float h=tube(o+d*t);
if(h<.0025*t||t>64.)break;
t+=h;
}
return t;
}
vec3 glowAt(vec3 p,vec3 n){
float k=floor(p.z/GAP);
vec3 sum=vec3(0.);
for(int i=0;i<3;i++){
float z=(k+float(i)-.5)*GAP;
vec2 c=path(z);
for(int j=0;j<2;j++){
vec3 L=vec3(c+vec2(j==0?-1.15:1.15,1.8),z)-p;
float r=dot(L,L);
sum+=max(dot(n,L)/sqrt(r),0.)/(1.+r*1.3);
}
}
return sum;
}
vec3 tail(vec3 o,vec3 d,float t){
float z=z0+11.+sin(time*.3)*2.-rush*5.;
vec3 sum=vec3(0.);
for(int i=0;i<2;i++){
vec3 c=vec3(path(z)+vec2(LANE+(i==0?-.3:.3),-.56),z)-o;
float along=dot(c,d);
if(along>0.&&along<t){
vec3 x=c-d*along;
sum+=vec3(1.,.08,.04)*(.0016/(dot(x,x)+.0003));
}
}
return sum;
}
vec3 surface(vec3 p,vec3 d,float t,bool first,out vec3 n,out float wet){
vec2 q=p.xy-path(p.z);
vec2 e=vec2(q.x*.78,q.y-.35);
vec3 c;
wet=0.;
if(q.y+1.<(1.85-length(e))*.8){
n=vec3(0.,1.,0.);
float grit=hash(floor(vec2(q.x,p.z)*42.));
c=vec3(.035,.035,.04)*(.7+.6*grit);
float a=abs(q.x);
float paint=smoothstep(.05,.03,a)*step(fract(p.z/3.),.42);
paint=max(paint,smoothstep(.05,.03,abs(a-1.32)));
c=mix(c,vec3(.62,.6,.52),paint);
wet=first?.75-paint*.5:0.;
}else{
n=normalize(vec3(-e.x*.78,-e.y,0.));
float across=atan(e.x,e.y)*7.,row=floor(across);
float along=p.z*2.2+mod(row,2.)*.5;
vec2 cell=vec2(fract(across),fract(along));
float line=mix(1.,smoothstep(.0,.07,cell.x)*smoothstep(.0,.04,cell.y),exp(-t*.14));
float pick=hash(vec2(row,floor(along)));
vec3 tile=q.y<-.25?vec3(.17,.2,.2):vec3(.5,.47,.42);
c=tile*(.78+.3*pick)*(.45+.55*line);
c*=.72+.28*smoothstep(-1.,.4,q.y);
}
vec3 lit=c*glowAt(p,n)*lampC*lampC*3.2;
float k=floor(p.z/GAP);
float mid=(fract(p.z/GAP)-.5)*GAP;
if(q.y>1.4){
float tube2=smoothstep(.15,.11,abs(abs(q.x)-1.15))*smoothstep(.6+rush*1.+.05,.6+rush*1.,abs(mid));
lit+=lampC*tube2*6.;
}
if(mod(k,7.)<.5&&abs(q.y-.45)<.12&&abs(q.x)>1.){
lit=mix(lit,vec3(.06,1.,.42)*1.4,smoothstep(.2,.18,abs(mid))*smoothstep(.09,.08,abs(q.y-.45)));
}
return lit;
}
void main(){
vec2 uv=(gl_FragCoord.xy-.5*res)/(.5*min(res.x,res.y));
vec3 o=vec3(path(z0)+vec2(LANE,-.42),z0);
vec3 aim=vec3(path(z0+6.)+vec2(LANE,-.2),z0+6.);
aim.xy+=look*vec2(1.5,.7);
vec3 f=normalize(aim-o),r=normalize(cross(vec3(0.,1.,0.),f)),u=cross(f,r);
float roll=(path(z0+7.).x-path(z0).x)*.035,cr=cos(roll),sr=sin(roll);
uv=vec2(cr*uv.x-sr*uv.y,sr*uv.x+cr*uv.y);
vec3 d=normalize(uv.x*r+uv.y*u+1.3*f);
vec3 fog=lampC*lampC*.12;
float t=march(o,d,44);
vec3 p=o+d*t,n;
float wet;
vec3 c=surface(p,d,t,true,n,wet);
if(wet>0.){
float f2=.04+.96*pow(1.-max(dot(-d,n),0.),5.);
float g=sin(p.z*7.+sin(p.x*5.))*sin(p.x*9.);
vec3 b=normalize(reflect(d,n)+vec3(g*.012,0.,g*.03));
vec3 from=p+n*.012;
float t2=march(from,b,30);
vec3 n2;float w2;
vec3 back=surface(from+b*t2,b,t2,false,n2,w2);
back=mix(back,fog,1.-exp(-t2*.05));
back*=smoothstep(.12,.8,dot(back,vec3(.33)));
back+=tail(from,b,t2);
c+=back*(f2*.7+.32)*wet;
}
c=mix(c,fog,1.-exp(-t*.06));
c+=tail(o,d,t);
c*=1.-dot(uv,uv)*.12;
c=1.-exp(-c*1.35);
gl_FragColor=vec4(pow(c,vec3(.8)),1.);
}`;
const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
function program(gl: WebGLRenderingContext) {
const made = gl.createProgram();
for (const [type, text] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, FRAGMENT]] as const) {
const shader = gl.createShader(type);
if (!made || !shader) return null;
gl.shaderSource(shader, text);
gl.compileShader(shader);
gl.attachShader(made, shader);
}
if (!made) return null;
gl.linkProgram(made);
return gl.getProgramParameter(made, gl.LINK_STATUS) ? made : null;
}
/* InfiniteTunnel: a night drive through a road tunnel that never ends. Tiled walls, lamp strips, a wet road, tail lights ahead. The mouse looks around; hold a press, Space or Enter to speed up. Runs only while on screen. */
export function InfiniteTunnel({
lamp = "#ff9a3c",
speed = 1,
label = "A night drive through an endless road tunnel. Hold to go faster.",
className = "aspect-[16/9] w-[28rem]",
}: InfiniteTunnelProps) {
const reduce = useReducedMotion();
const boxRef = useRef<HTMLButtonElement>(null);
const plainRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const box = boxRef.current;
const plain = plainRef.current;
if (!box || !plain) return;
const canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%";
const gl = canvas.getContext("webgl", { alpha: false, antialias: false });
const made = gl && program(gl);
if (!gl || !made) return;
gl.useProgram(made);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const corner = gl.getAttribLocation(made, "p");
gl.enableVertexAttribArray(corner);
gl.vertexAttribPointer(corner, 2, gl.FLOAT, false, 0, 0);
const at = (name: string) => gl.getUniformLocation(made, name);
gl.uniform3fv(at("lampC"), rgb(lamp));
let raf = 0;
let last = 0;
let inView = false;
let time = 0;
let z = 44;
let rush = 0;
let lookX = 0;
let lookY = 0;
let goalX = 0;
let goalY = 0;
let holding = false;
const draw = () => {
gl.uniform2f(at("look"), lookX, lookY);
gl.uniform1f(at("z0"), z);
gl.uniform1f(at("time"), time % 1000);
gl.uniform1f(at("rush"), rush);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
const frame = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000 || 0);
last = now;
time += dt;
rush += ((holding ? 1 : 0) - rush) * Math.min(1, dt * 3);
z += dt * speed * (8 + rush * 30);
lookX += (goalX - lookX) * Math.min(1, dt * 4);
lookY += (goalY - lookY) * Math.min(1, dt * 4);
draw();
raf = inView ? requestAnimationFrame(frame) : 0;
};
const start = () => {
if (inView && !raf && !reduce) raf = requestAnimationFrame(frame);
};
const fit = () => {
const { width, height } = box.getBoundingClientRect();
if (!width || !height) return;
const scale = Math.min(Math.max(window.devicePixelRatio, 1.5), 2, Math.sqrt(MAX_PIXELS / (width * height)));
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform2f(at("res"), canvas.width, canvas.height);
draw();
};
const hold = () => {
if (reduce) {
z += 7;
draw();
} else holding = true;
};
const free = () => {
holding = false;
};
const point = (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
const r = box.getBoundingClientRect();
goalX = ((e.clientX - r.left) / r.width - 0.5) * 2;
goalY = (0.5 - (e.clientY - r.top) / r.height) * 2;
if (reduce) {
lookX = goalX;
lookY = goalY;
draw();
}
};
const leave = () => {
goalX = 0;
goalY = 0;
holding = false;
};
const keyDown = (e: KeyboardEvent) => {
if (e.key !== " " && e.key !== "Enter") return;
e.preventDefault();
if (!e.repeat) hold();
};
const keyUp = (e: KeyboardEvent) => {
if (e.key === " " || e.key === "Enter") free();
};
fit();
box.append(canvas);
plain.hidden = true;
const sized = new ResizeObserver(fit);
sized.observe(box);
const watch = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
start();
});
watch.observe(box);
const lost = () => {
canvas.remove();
plain.hidden = false;
};
canvas.addEventListener("webglcontextlost", lost);
box.addEventListener("pointerdown", hold);
box.addEventListener("pointerup", free);
box.addEventListener("pointercancel", free);
box.addEventListener("pointermove", point);
box.addEventListener("pointerleave", leave);
box.addEventListener("blur", free);
box.addEventListener("keydown", keyDown);
box.addEventListener("keyup", keyUp);
return () => {
cancelAnimationFrame(raf);
sized.disconnect();
watch.disconnect();
canvas.removeEventListener("webglcontextlost", lost);
box.removeEventListener("pointerdown", hold);
box.removeEventListener("pointerup", free);
box.removeEventListener("pointercancel", free);
box.removeEventListener("pointermove", point);
box.removeEventListener("pointerleave", leave);
box.removeEventListener("blur", free);
box.removeEventListener("keydown", keyDown);
box.removeEventListener("keyup", keyUp);
gl.getExtension("WEBGL_lose_context")?.loseContext();
canvas.remove();
plain.hidden = false;
};
}, [lamp, speed, reduce]);
return (
<button
ref={boxRef}
type="button"
aria-label={label}
className={`relative block cursor-pointer touch-pan-y overflow-hidden bg-[#0c0806] ${className}`}
>
<span
ref={plainRef}
className="absolute inset-0"
style={{ background: `radial-gradient(circle at 50% 46%, ${lamp} 0, ${lamp}55 12%, #0c0806 46%)` }}
/>
</button>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/infinite-tunnel.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/infinite-tunnel-demo.tsx
import { InfiniteTunnel } from "@/components/ui/infinite-tunnel"; export default function InfiniteTunnelExample() { return <InfiniteTunnel className="size-full min-h-[260px]" />; }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| lamp | string | "#ff9a3c" | Six-digit hex colour of the tunnel lamps. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| label | string | "A night drive through an endless road tunnel..." | Name read out by screen readers. |
| className | string | "aspect-[16/9] w-[28rem]" | Set the size here. |