Skip to content

IrisBackgroundsSlipstream

Slipstream

A loose bundle of teal-and-green light trails funnelling into one bright knot, a couple of survivors peeling off and burning out beyond it.

slipstream

Slipstream

A dozen thin streaks spread wide and near-horizontal across the left of the frame, each carrying its own travelling highlight so the bundle reads as motion caught mid-blur rather than a printed line. Moving right, they funnel together into one tight, braided knot — teal deepening to a near-white core where the most of them overlap — before most burn out right there and a couple of survivors peel off on their own diverging lines, fading toward the upper-right edge.

The cursor takes hold of the knot itself: it leans toward the pointer and cinches tighter, the nearby strands bowing in and flaring a little brighter, as if the whole braid were being pulled taut by hand. Move away and it eases back into its own slow drift.

  • cool
  • pointer-driven
  • motion-trail
Family
slipstream
Status
Available
Licence
Free

Install

No installation needed — self-contained, paste-in code.

Usage

Drop it straight into a page.

example.tsx
import { SlipstreamField } from "./SlipstreamField";

export default function Example() {
  return (
    // Fills its nearest positioned ancestor (it renders itself `absolute
    // inset-0`) — give it a sized, relatively positioned box.
    <div className="relative isolate h-[32rem] w-full overflow-hidden rounded-2xl">
      <SlipstreamField />
    </div>
  );
}

Component

The real source, exactly as it ships — multiple files, kept together.

"use client";

import { useEffect, useRef } from "react";
import { mountShaderSurface } from "@/lib/shader-surface";

/**
 * A live WebGL field of long-exposure light trails at speed — a loose bundle
 * of thin teal-to-green streaks, spread wide and near-horizontal on the left,
 * that funnel into one tight braided knot right of centre before a couple of
 * survivors peel off toward the upper-right and burn out. Each streak carries
 * its own travelling highlight, so the bundle reads as motion caught mid-blur
 * rather than a printed line — the look of anamorphic light-speed trails.
 *
 * The pointer takes hold of the knot itself: it leans toward the cursor and
 * cinches tighter, the nearby strands bowing in with it and flaring a little
 * brighter, as if the whole braid were being pulled taut by hand. Move away
 * and it eases back to its own slow drift.
 *
 * One of the reusable background fields (`FilamentField`, `CurrentField`,
 * `RoadField`, …). Drop it into any `position: relative`/`isolate` parent —
 * it fills the box. Built on `lib/shader-surface.ts`, so every degradation
 * path is already handled: no WebGL, a blocked or lost context, a hidden
 * tab, or `prefers-reduced-motion` all leave the CSS
 * `.iris-slipstreamfield__floor` underneath visible — a still frame in the
 * same palette, never a blank box.
 *
 * Like `FilamentField` / `GauzeField`, the palette is a fixed teal/green set
 * passed as uniforms rather than read from the accent ramp — the near-white
 * core and the acid-green fringe live nowhere in the site's ramp, so the CSS
 * floor carries the colour rather than a token read.
 *
 * Reading guard: when `guardSelector` resolves to an element, the field
 * measures that block every frame and clamps its own luminance under a
 * ceiling inside that region (hue and saturation untouched). `null` (the
 * default) turns the guard off — for decorative use where nothing sits on
 * top.
 */

/* Palette, sRGB 0–1. Uniforms, not tokens — see the note above. */
const PALETTE: Record<string, [number, number, number]> = {
  u_ground: [0.006, 0.011, 0.013], // the near-black behind everything
  u_deep: [0.02, 0.1, 0.095], // the dim, barely-lit body of a strand
  u_teal: [0.07, 0.58, 0.58], // the mid strand colour, and the braid's main mass
  u_green: [0.32, 0.86, 0.4], // the acid-green fringe on the brightest strands
  u_flare: [0.86, 1.0, 0.96], // the near-white core riding the hottest strands
};

const FRAG = `
uniform vec2  u_res;
uniform float u_time;
uniform float u_scale;
uniform vec3  u_pointer;  /* x, y (0 bottom .. 1 top), presence 0..1 */

uniform vec3 u_ground;
uniform vec3 u_deep;
uniform vec3 u_teal;
uniform vec3 u_green;
uniform vec3 u_flare;

uniform vec4  u_readA;
uniform float u_guard;

const int STRANDS = 11;
const float KNOT_X = 0.66;

void main() {
  vec2 res = u_res / u_scale;
  vec2 uv  = gl_FragCoord.xy / u_scale / res;            /* 0..1, y up */
  float aspect = res.x / res.y;
  vec2 P = (uv - 0.5) * vec2(aspect, 1.0);

  vec2 ptr = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);

  /* the knot itself: a fixed home, nudged and cinched by the pointer */
  vec2 knotHome = (vec2(KNOT_X, 0.56) - 0.5) * vec2(aspect, 1.0);
  vec2 toP = knotHome - ptr;
  float dP = length(toP);
  float grip = u_pointer.z * exp(-dP * dP * 3.0);
  vec2 knot = knotHome - toP * grip * 0.5;

  vec3 col = mix(u_ground, u_deep, 0.08 + 0.1 * smoothstep(0.0, 1.0, uv.x));
  float hot = 0.0;

  for (int i = 0; i < STRANDS; i++) {
    float fi = float(i);
    float seed = fi * 1.618;
    float k = fi / float(STRANDS - 1);

    /* left-edge spread: across most of the frame height, ordered but not
       ruler-even */
    float yBase = mix(0.1, 0.92, k) + (fract(seed * 3.3) - 0.5) * 0.08;

    float wob = fbm(vec2(uv.x * 2.6 + seed, seed * 2.0 + u_time * 0.02)) * 0.05;

    float toKnot = clamp((KNOT_X - uv.x) / KNOT_X, 0.0, 1.0);
    float spread = smoothstep(0.0, 1.0, toKnot);

    float survive = step(0.62, fract(seed * 5.7));
    float past = clamp((uv.x - KNOT_X) / (1.0 - KNOT_X), 0.0, 1.0);

    /* the knot's own vertical position (P.y's scale is 1.0, so the pointer
       offset carries over to uv space unchanged) */
    float knotYuv = 0.56 + (knot.y - knotHome.y);

    float exitLean = (fract(seed * 9.1) - 0.5) * 1.4;
    float yPast = knotYuv + past * exitLean - past * past * 0.12;

    float y = mix(yBase, knotYuv, 1.0 - spread);
    /* the touch cinches the bundle tighter as it nears the knot */
    y = mix(y, knotYuv, grip * 0.35 * spread);
    y = mix(y, yPast, past * survive);
    y += wob * (0.4 + 0.6 * spread);
    y += sin(u_time * 0.06 + seed * 4.0) * 0.006;

    float alive = mix(1.0, 1.0 - smoothstep(0.0, 0.45, past), 1.0 - survive);
    if (alive <= 0.001) continue;

    /* distance in aspect-corrected space so thickness reads the same at any
       box ratio */
    float dyUv = uv.y - y;
    float dyP = dyUv; // vertical only — no aspect correction needed on y
    float thickness = mix(950.0, 2600.0, spread * 0.5 + 0.3);
    float body = exp(-dyP * dyP * thickness) * alive;
    float core = exp(-dyP * dyP * thickness * 9.0) * alive;

    /* the travelling highlight: a bright run sliding along the strand,
       rightward, faster near the knot where the light is compressed */
    float flow = uv.x * 9.0 - u_time * (0.35 + spread * 0.5) - seed * 3.0;
    float m = fbm(vec2(flow, seed * 2.2));
    float bright = smoothstep(-0.1, 0.6, m);

    float w = mix(0.35, 1.1, spread) * (0.6 + 0.4 * bright);
    vec3 tint = mix(u_deep, u_teal, smoothstep(0.0, 0.7, body));
    tint = mix(tint, u_green, smoothstep(0.5, 1.0, bright) * 0.5);

    col += tint * body * w * 1.6;
    col += u_teal * body * body * w * 0.5;
    hot += core * w * (0.5 + 0.5 * bright);
  }

  col += u_flare * hot * 1.15;

  /* the knot's own bloom, and the touch that cinches it tighter */
  float dKnot = length(uv - vec2(KNOT_X, 0.56));
  col += u_teal * exp(-dKnot * dKnot * 20.0) * 0.35;
  col += u_flare * grip * 0.4;
  col += u_green * grip * hot * 0.5;

  col = max(col, 0.0);

  /* ---- the reading guard (see TileField for the full rationale) ---- */
  vec2 rd = abs(uv - u_readA.xy) / max(u_readA.zw, vec2(0.02));
  float md = mix(max(rd.x, rd.y), length(rd), 0.4);
  float guardBand = 1.0 - smoothstep(0.72, 2.1, md);
  col = mix(col, holdUnder(col, 0.09), guardBand * u_guard);

  col += (bayer8(gl_FragCoord.xy) - 0.5) * (2.2 / 255.0);

  gl_FragColor = vec4(col, 1.0);
}
`;

export interface SlipstreamFieldProps {
  className?: string;
  /**
   * CSS selector for the block the reading guard should keep readable,
   * resolved against `document`. `null` (the default) turns the guard off.
   */
  guardSelector?: string | null;
}

export function SlipstreamField({ className, guardSelector = null }: SlipstreamFieldProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const guardOn = guardSelector != null;
    let guardEl: Element | null | undefined;
    const readGuardEl = () => {
      if (guardEl === undefined) {
        guardEl = guardOn ? document.querySelector(guardSelector as string) : null;
      }
      return guardEl;
    };

    return mountShaderSurface(canvas, {
      fragment: FRAG,
      uniforms: [...Object.keys(PALETTE), "u_readA", "u_guard"],
      onInit: (gl, u) => {
        for (const name of Object.keys(PALETTE)) {
          if (u[name]) gl.uniform3fv(u[name], PALETTE[name]);
        }
        if (u.u_readA) gl.uniform4f(u.u_readA, 0.5, 0.5, 0.44, 0.32);
        if (u.u_guard) gl.uniform1f(u.u_guard, guardOn ? 1 : 0);
      },
      onFrame: (gl, u, s) => {
        if (!guardOn || !u.u_readA) return;
        let cx = 0.5, cy = 0.5, hw = 0.44, hh = 0.32;
        const el = readGuardEl();
        const { rect } = s;
        if (el && rect.width > 0 && rect.height > 0) {
          const r = el.getBoundingClientRect();
          const padX = rect.width * 0.09;
          const padY = rect.height * 0.11;
          cx = (r.left + r.width / 2 - rect.left) / rect.width;
          cy = 1 - (r.top + r.height / 2 - rect.top) / rect.height;
          hw = (r.width / 2 + padX) / rect.width;
          hh = (r.height / 2 + padY) / rect.height;
        }
        gl.uniform4f(u.u_readA, cx, cy, hw, hh);
      },
      onPainted: () => canvas.setAttribute("data-shader", "on"),
      /* No onIdle — same contract as FilamentField / GauzeField: once
         painted, the last frame stays on screen while the surface is parked
         off-view, so scrolling away and back does not crossfade to the
         static floor. A lost context still clears it below. */
      onLost: () => canvas.removeAttribute("data-shader"),
      maxPixels: 2_200_000,
      dprCap: 1.5,
    });
  }, [guardSelector]);

  return (
    <div
      className={`absolute inset-0 overflow-hidden${className ? ` ${className}` : ""}`}
      aria-hidden="true"
    >
      <div className="absolute inset-0 [background:radial-gradient(_46%_60%_at_66%_56%,oklch(0.97_0.02_175)_0%,oklch(0.72_0.19_165_/_0.85)_16%,oklch(0.5_0.14_175_/_0.4)_38%,transparent_62%_),radial-gradient(_70%_55%_at_8%_30%,oklch(0.4_0.1_175_/_0.5)_0%,transparent_60%_),radial-gradient(_60%_50%_at_4%_78%,oklch(0.35_0.09_165_/_0.4)_0%,transparent_58%_),radial-gradient(_50%_60%_at_92%_10%,oklch(0.55_0.16_145_/_0.35)_0%,transparent_62%_),oklch(0.06_0.015_195)] after:content-[''] after:absolute after:inset-0 after:[background:repeating-linear-gradient(_4deg,transparent_0_9px,oklch(0.6_0.14_170_/_0.16)_9px_11px_)] after:[-webkit-mask-image:radial-gradient(_60%_70%_at_66%_56%,transparent_8%,#000_42%,transparent_88%_)] after:[mask-image:radial-gradient(_60%_70%_at_66%_56%,transparent_8%,#000_42%,transparent_88%_)]" />
      <canvas ref={canvasRef} className="absolute inset-0 w-full h-full opacity-0 transition-opacity duration-[--duration-slow] ease-[--ease-standard] data-[shader=on]:opacity-100" />
    </div>
  );
}

More backgrounds

Custom work

Need one that isn't in the catalogue?

Describe what you're after and I'll reply by email — no promise on turnaround yet, this is a new channel, not a service with a set price or queue.

Fardin Omor Afnan

Fardin Omor Afnan

Reads and answers every request himself

Or start with a section.

Whole page sections, composed and ready to drop in — take one and build the rest of the page around it.

Browse sections