Backgrounds

Warp Tunnel

A hyperspace speed tunnel on one canvas — tapered streaks leave a movable vanishing point and accelerate outward with perspective foreshortening.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * Which semantic token the streaks and the core glow are painted with.
 * The value is written onto the canvas as an inline `color`; the *computed*
 * string is read back and handed to the 2D context verbatim, so oklch(),
 * color-mix() and any rebranded palette work without parsing a colour by hand.
 */
export type WarpTunnelTone =
  | "primary"
  | "foreground"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/warp-tunnel.json

Prompt

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 what t = 1 means; 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 same seed always paints the same tunnel.
  • Field survives prop changes — the streaks and the eased origin live in a ref, so changing speed, tone or the vanishing point re-runs the effect without resetting a tunnel that is already flying; only a new seed clears it.
  • Coarse-pointer degradation — a touch surface has no hover to steer with, so interactive is 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: reduce the 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.

On This Page