Warp Tunnel
A hyperspace speed tunnel on one canvas — tapered streaks leave a movable vanishing point and accelerate outward with perspective foreshortening.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/warp-tunnel.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "WarpTunnel" component — a hyperspace
speed tunnel painted on one canvas, used as a hero / launch / transition
backdrop. Its only dependency is a cn() class merger (clsx + tailwind-merge).
Contract
- export function WarpTunnel(props): props extend React.ComponentProps<"div">
(rest props spread onto the root, children render above the canvas) plus:
- count?: number (default 150) — streaks in flight. The effective count is
min(count, floor(area / 320), 900): an area cap so a small card is not
carpeted, and a hard ceiling so a typo'd count cannot freeze the tab.
- speed?: number (default 1) — multiplier on the flight speed; 0 freezes the
tunnel into a still frame (and, with interactive off, the rAF loop never
starts at all).
- vanishingPoint?: { x: number; y: number } (default { x: 0.5, y: 0.5 }) —
the origin of the perspective, in 0..1 of the container box, clamped so it
always sits inside it (a point outside would leave rays that never exit).
- streakLength?: number (default 1) — multiplier on the trail; 0.3 is a dot
field, 2 is full hyperspace.
- tone?: "primary" | "foreground" | "muted" | "chart-1".."chart-5"
(default "primary") — which semantic token the streaks are painted with.
- glow?: boolean (default true) — soft core of light at the vanishing point.
- interactive?: boolean (default false) — the pointer steers the vanishing
point.
- seed?: number (default 11) — integer seed for the streak layout.
- "use client": canvas, rAF, observers and pointer events.
- Clamp every numeric prop before use and treat a non-finite value as the
default: a negative speed would run the tunnel backwards and pile every
streak onto the vanishing point.
Behavior
- DOM: root div "relative isolate overflow-hidden" holding (a) a canvas that is
aria-hidden, pointer-events-none, absolute inset-0 and size-full — the
size-full matters, an absolutely positioned replaced element with inset-0
alone renders at its intrinsic 300x150 — and (b) a "relative z-10" wrapper for
children, so content always sits above the field and the canvas can never
intercept a click. The component paints NO background of its own: the surface
belongs to the consumer.
- Simulation in progress space, not pixels. Each streak owns a heading and a
progress t: 0 at the vanishing point, 1 at the farthest corner. Its distance
from the origin is radius = maxR * t², so dr/dt is proportional to t — the
perspective foreshortening: a streak crawls while it is "far away" and rushes
past at the rim, growing and widening as it comes. Because the state is
progress and not px, a resize only changes what t = 1 means: nothing has to
be migrated, rescaled or reseeded.
- Streak geometry: draw a QUAD, not a line. Head at radius(t), tail at
radius(t - trail); width at both ends scales with progress and the tail is a
quarter of the head. That taper is the entire difference between a speed line
and a rectangle. Alpha fades in over the innermost 10% of progress so nothing
pops into existence at the vanishing point.
- Recycling: each streak leaves the frame at its OWN exit distance — the
ray/box intersection along its heading, recomputed against the current origin
— not at maxR. With an off-centre vanishing point that is what keeps the
field evenly populated instead of simulating streaks that left the canvas
seconds ago. Past it, the streak restarts at t = 0 on a fresh heading hashed
from its own cycle counter (still deterministic, no Math.random).
- Deterministic layout: heading, initial progress, rate, width and alpha all
come from an integer hash of (streakId, salt), where streakId mixes the seed
with the streak's index. Addressing streaks by index rather than pulling from
a PRNG stream means growing the field after a resize never reshuffles the
ones already flying, and the same seed always paints the same tunnel.
Math.random() is never called — not during render (purity/SSR) and not in the
loop (screenshots must be reproducible). Seed the initial progress across
each ray's OWN lifetime (sqrt(exit / maxR) + trail), not across [0, 1]: with
an off-centre origin the short rays would otherwise be seeded past their exit
and recycle on frame one, leaving an empty wedge in the very first — and
under reduced motion, the only — frame.
- The field survives prop changes. Keep the streaks, the eased origin and the
seed in a ref, so changing speed / tone / streakLength / the vanishing point
re-runs the effect without resetting a tunnel that is already flying; only a
new seed clears it.
- Vanishing point easing: the drawn origin chases the prop (and the pointer)
with 1 - exp(-dt * 5) on REAL dt, so slowing the tunnel down never makes
steering feel laggy, and a change of vanishingPoint swings the corridor
across instead of teleporting it. When the loop cannot run (first paint,
speed 0 with no pointer, reduced motion) the origin snaps instead, or the
still frame would keep radiating from the old point for ever.
- Pointer steering (interactive): pointermove writes clientX/clientY into a ref
— never state, a trackpad emits far more than 60 events/s and children must
not re-render for a decoration — and the loop does the single
getBoundingClientRect once per frame, then pulls the origin 40% of the way
toward the pointer. pointerleave clears the flag. Nothing is captured and
nothing calls preventDefault, so a touch drag still scrolls the page. On
matchMedia("(pointer: coarse)") steering is off at the source (no hover to
steer with), re-read live through a matchMedia listener so plugging in a
mouse mid-session enables it.
- Ink: the canvas carries the tone token as an inline `color`; the loop reads
getComputedStyle(canvas).color back and assigns that string straight to
fillStyle, with alpha on globalAlpha. Never hand-parse a colour — passing the
computed string through means oklch(), color-mix() and any rebranded palette
all work. The core glow is a stack of 12 concentric discs at alpha 0.045
whose source-over alphas accumulate into a soft falloff, which avoids ever
handing a colour string to addColorStop.
- Sizing: a ResizeObserver observes the canvas itself (not the root, whose
padding would offset the box); its first callback is the initial sizing.
devicePixelRatio is capped at 2, and ctx.setTransform is re-applied after
every resize because writing canvas.width resets the context.
- Power: the rAF loop runs only when an IntersectionObserver says the canvas is
on screen, document.visibilityState is "visible", motion is allowed and there
is something to animate (speed > 0 or pointer steering). dt is clamped to
1/30s so a backgrounded tab cannot teleport the tunnel on resume, and the
time base resets when the loop restarts.
- Theme flips: a MutationObserver on <html> (class/style/data-theme) re-reads
the ink and repaints the still frame when the loop is paused, so a frozen
tunnel never keeps the old theme's colour.
- prefers-reduced-motion: reduce — read via useSyncExternalStore (server
snapshot false, so it is hydration-safe) and kept in the effect deps, so a
mid-session change is honoured. Under reduce the loop never starts: exactly
one still frame is painted — a full tunnel of streaks, each already spread
along its own ray — and the pointer is ignored.
- Cleanup on unmount: cancelAnimationFrame, both observers, the
MutationObserver and the visibilitychange listener.
Rendering & styling
- Semantic tokens only, zero colour literals: the ink is var(--primary) /
var(--foreground) / var(--muted-foreground) / var(--chart-1..5) resolved
through the canvas's own computed style, so light/dark comes for free. Alpha
lives in globalAlpha, never in the colour string.
- Merge the consumer className via cn() on the root; the canvas keeps its own
classes.
- Accessibility: the canvas is aria-hidden and pointer-events-none, pure
decoration; children stay fully interactive, selectable and above the field.
Streaks crossing body copy hurt legibility — put a scrim (a radial
color-mix(in oklab, var(--card) 84%, transparent) wash) between the canvas
and the copy, as the demo does; that is the page's job, not the component's.
Customization levers
- Perspective: the radius law (maxR * t²) is the whole feel. A higher exponent
makes the centre calmer and the rim more violent; a linear law removes the
foreshortening entirely and reads as a flat star burst.
- Drama: streakLength (trail span in progress units) and HEAD_WIDTH / the 0.25
tail ratio decide whether this is dust, speed lines or a full hyperjump.
BASE_RATE (0.32 progress/s) is the nominal crossing time; per-streak jitter
of ±30% on rate, ±45% on width and 0.28..0.83 on alpha is what keeps the
field from marching in lockstep.
- Cost: count is the cost knob and it is linear — one 4-point quad fill per
streak per frame, plus 12 disc fills for the glow. MIN_AREA_PER_STREAK (320)
and the 900 ceiling are the safety valves; drop MAX_DPR to 1 to halve fill
cost on retina if you ship very large heroes.
- Composition: vanishingPoint aims the corridor — put it under the headline for
a centred hero, at 0.2/0.8 for a diagonal corridor, or at an edge so the
tunnel only grazes the section. glow={false} when the copy sits right on top
of the origin.
- Palette: tone maps to a token, so add an entry for var(--accent) or a brand
token and the whole field follows it, dark mode included.
- Steering: POINTER_PULL (0.4) is how far the origin travels toward the
pointer, POINTER_EASE (5/s) how eagerly it chases. Feed the origin from
scroll progress instead of the pointer for a corridor that banks as the page
moves.Concepts
- Vanishing-point perspective — every streak is a ray from one origin and its distance grows as
maxR · t², so speed is proportional to progress. That single law produces the foreshortening: near the origin streaks are short, dim and slow; at the rim they are long, wide and fast. Moving the origin re-aims the whole corridor without touching a single streak. - Progress-space state — a streak stores a heading and a scalar
t, never pixels. A resize therefore only changes whatt = 1means; nothing is migrated, rescaled or reseeded, and the same field renders identically in a 320px card and a full-bleed hero. - Tapered quad, not a stroked line — head and tail are drawn as a four-point polygon whose width scales with progress and whose tail is a quarter of the head. A round-capped line of constant width reads as a stick; the taper is what makes it read as light.
- Per-ray exit distance — recycling happens when a streak's tail passes the ray/box intersection along its own heading, not at a global radius. With an off-centre vanishing point that keeps the corridor evenly populated instead of simulating streaks that left the canvas seconds ago.
- Seeded layout — heading, initial progress, rate, width and alpha are all integer hashes of
(streakId, salt), so the server and the client agree, growing the field after a resize never reshuffles it, and the sameseedalways paints the same tunnel. - Field survives prop changes — the streaks and the eased origin live in a ref, so changing
speed,toneor the vanishing point re-runs the effect without resetting a tunnel that is already flying; only a newseedclears it. - Coarse-pointer degradation — a touch surface has no hover to steer with, so
interactiveis switched off at the source there rather than fighting the page scroll; the media query is read through a live listener, so plugging in a mouse enables it mid-session. - Reduced-motion still frame — under
prefers-reduced-motion: reducethe loop never starts, but the single painted frame is a complete tunnel with every streak already spread along its own ray, so motion off never means content missing.
Metaball Blobs
A gooey organic background — token-tinted blobs drift on seeded orbits and fuse into one body through an SVG alpha-threshold filter, optionally leaning toward the pointer.
Progressive Blur
A gradual-blur edge treatment — stacked backdrop-filter layers with geometrically growing radii, each masked to its own slice, so content melts into frosted glass toward one edge.