Skip to content

IrisBackgroundsFiber Drift

Fiber Drift

A real three.js scene, not a flat shader: five ribbons of glowing particle dust sweeping up from the left edge, cresting in a soft haze, and running back down past the right — a direct build of the reference photo, with a camera the pointer can turn.

arc

Fiber Drift

Five ribbons of particle dust curve up from below the left edge, crest left-of-centre with the far side of the bundle pushed back into a soft haze, and run back down past the right — closer to the camera and brighter at both ends, the way the reference photo's own bundle reads nearer the lens at its mouth than at its crest. The bottom-most ribbon is tight and near-solid; each one above it loosens and spreads wider, fraying into loose sparkle the way the reference's own outer trails do. A soft sky-blue wash sits over the crest, cooling into a darker navy toward the corners.

This is genuine 3D geometry — curved tubes and point-sprite dust sampled along each curve's own normal frame, composited through a bloom pass for the glow — not a 2D approximation, which is what lets the pointer actually turn the camera a few degrees either way, eased, the way turning your head reveals a little more of a tunnel of light from the side. A soft on-screen spotlight independently follows the cursor, brightening whichever dust passes under it, and the whole bundle carries its own slow idle sway even before the pointer arrives.

  • cool
  • pointer-driven
  • particle
  • 3d
  • motion-trail
Family
arc
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

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

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">
      <FiberDriftField />
    </div>
  );
}

Component

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

"use client";

import { useEffect, useRef } from "react";
import * as THREE from "three";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";

/**
 * A real three.js scene built to match one reference photo exactly, not an
 * approximation drawn with a flat fragment shader: five ribbons of glowing
 * particle dust, in actual 3D space, sweeping up from below the left edge,
 * cresting left-of-centre with the far side of the bundle pushed back into
 * a soft haze, and running back down past the right edge — closer to the
 * camera (bigger, brighter) at both ends, the way the reference photo's own
 * bundle reads nearer the lens at its mouth and further away at its crest.
 * The bottom-most ribbon is tight and near-solid; each one above it loosens
 * and spreads wider, the way the reference photo's own outer trails fray
 * into loose sparkle.
 *
 * Unlike this catalogue's other fields, this one is not a full-screen
 * fragment shader over a flat quad — it is a perspective camera looking at
 * real curved geometry (`THREE.CatmullRomCurve3` tubes plus point-sprite
 * dust sampled along each curve's own normal/binormal frame), composited
 * through `UnrealBloomPass` for the glow the reference photo carries.
 * That's what buys the two things a flat shader can't: true perspective
 * (things further from camera read smaller and hazier on their own, no
 * hand-authored falloff) and a camera the pointer can actually turn.
 *
 * The pointer turns the camera itself, a few degrees either way, eased —
 * the way turning your head reveals a little more of a tunnel of light
 * from the side — while a soft on-screen spotlight independently follows
 * the cursor, brightening whichever dust passes under it. The bundle also
 * carries its own slow idle sway, so it's alive even before the pointer
 * arrives.
 *
 * One of the reusable background fields (`SkeinField`, `NovaField`, …).
 * Drop it into any `position: relative`/`isolate` parent — it fills the
 * box. Degradation follows the same contract as every other field: no
 * WebGL, a blocked or lost context, a hidden tab, or `prefers-reduced-
 * motion` all leave the CSS `.iris-fiberdriftfield__floor` underneath
 * visible — a still frame in the same palette, never a blank box. Off-
 * screen the scene is never even constructed (`IntersectionObserver`
 * gates it, same reasoning as `lib/shader-surface.ts`'s lazy WebGL
 * context: a page can hold many of these, and browsers cap live contexts).
 *
 * Reading guard: when `guardSelector` resolves to an element, a final
 * composite pass measures that block every frame and clamps luminance
 * under a ceiling inside it (hue and saturation untouched) — the same
 * `holdUnder` rule the flat-shader fields apply per-pixel, run here as a
 * post-process instead since the scene itself has no single fragment
 * shader to inject it into. `null` (the default) turns the guard off.
 */

/* Palette, sRGB 0–1. A fixed set, not the accent ramp — the reference
   photo's saturated sky-blue lives nowhere in the site's ramp. */
const PALETTE = {
  deep: [0.03, 0.07, 0.15] as const, // darkest navy, the corners
  navy: [0.07, 0.16, 0.32] as const, // mid ground away from the glow
  glowHi: [0.22, 0.38, 0.58] as const, // the soft sky-blue wash over the crest
  glowCore: [0.34, 0.5, 0.7] as const, // the small hot spot inside that wash
  particle: [0.62, 0.8, 0.97] as const, // the dust each ribbon is made of
  core: [0.95, 0.98, 1.0] as const, // the hot white centreline and comets
};

/* Only the ribbons themselves are meant to bloom — the background wash is
   a flat, already-soft gradient in the reference photo, not a light
   source. Bloom runs on the whole composited frame, so the ribbon
   material colours are deliberately pushed over 1.0 (legitimate under the
   ACES tonemap below) while the background stays well under the pass's
   threshold and reads crisp instead of blown out. */
const EMISSIVE_BOOST = 1.6;

function srgb(c: readonly [number, number, number]): THREE.Color {
  return new THREE.Color().setRGB(c[0], c[1], c[2], THREE.SRGBColorSpace);
}

/* The bundle's shared spine, in world units. Both ends run outside the
   ~±5.8 x / ±3.3 y frustum at the working camera distance, so every ribbon
   visibly enters and exits past the frame edges. The crest (index 2–3) is
   pushed back in z — further from the camera, so it reads smaller and
   hazier on its own, the way the reference photo's own crest sits a touch
   softer than its mouth. */
const SPINE: [number, number, number][] = [
  [-8.0, -3.2, 1.8],
  [-4.0, -0.4, 0.6],
  [-1.0, 1.9, -1.3],
  [1.3, 1.65, -1.9],
  [4.3, -0.4, -0.2],
  [8.2, -2.8, 1.6],
];

const STRAND_COUNT = 5;
const DUST_PER_STRAND = 620;
const COMETS_PER_STRAND = 3;

interface Strand {
  curve: THREE.CatmullRomCurve3;
  t: number; // 0 = bottom-most / tightest, 1 = top-most / loosest
  speed: number; // comet travel speed, sign gives direction
  phase: number;
}

function buildStrands(): Strand[] {
  const strands: Strand[] = [];
  for (let i = 0; i < STRAND_COUNT; i++) {
    const sNorm = (i - (STRAND_COUNT - 1) / 2) / ((STRAND_COUNT - 1) / 2); // -1..1
    const t = (sNorm + 1) / 2; // 0 bottom .. 1 top
    /* asymmetric on purpose: the bottom cluster sits tight (near-merged,
       like the reference photo's own lower band) while the top loosens
       out much wider — not a symmetric fan either side of the centre. */
    const stackY =
      sNorm < 0 ? sNorm * 0.5 : Math.pow(sNorm, 0.85) * 1.85;
    const stackZ = sNorm * 0.55;
    const ampScale = 1 + sNorm * 0.1;

    const pts = SPINE.map(([x, y, z], k) => {
      const rise = k === 2 || k === 3 ? y * ampScale : y;
      return new THREE.Vector3(x, rise + stackY, z + stackZ);
    });

    strands.push({
      curve: new THREE.CatmullRomCurve3(pts, false, "catmullrom", 0.45),
      t,
      speed: (i % 2 === 0 ? 1 : -1) * (0.028 + t * 0.02),
      phase: (i * 0.618) % 1,
    });
  }
  return strands;
}

function buildDustGeometry(
  strand: Strand,
  colorCore: THREE.Color,
  colorParticle: THREE.Color
): THREE.BufferGeometry {
  const count = DUST_PER_STRAND;
  const positions = new Float32Array(count * 3);
  const colors = new Float32Array(count * 3);
  const sizes = new Float32Array(count);
  const phases = new Float32Array(count);

  const spread = THREE.MathUtils.lerp(0.07, 0.38, strand.t);
  const sizeMax = THREE.MathUtils.lerp(0.115, 0.062, strand.t);

  const tangent = new THREE.Vector3();
  const up = new THREE.Vector3();
  const normal = new THREE.Vector3();
  const binormal = new THREE.Vector3();
  const pos = new THREE.Vector3();
  const col = new THREE.Color();

  for (let i = 0; i < count; i++) {
    const along = Math.random();
    const p = strand.curve.getPointAt(along);
    strand.curve.getTangentAt(along, tangent).normalize();
    up.set(0, 1, 0);
    if (Math.abs(tangent.dot(up)) > 0.92) up.set(1, 0, 0);
    normal.crossVectors(tangent, up).normalize();
    binormal.crossVectors(tangent, normal).normalize();

    /* sum-of-uniforms for a cheap approximate gaussian: peaks at 0, so
       most dust sits near the centreline and thins out toward the edge. */
    const g = (Math.random() + Math.random() + Math.random() - 1.5) / 1.5;
    const gb = Math.random() * 2 - 1;

    pos
      .copy(p)
      .addScaledVector(normal, g * spread)
      .addScaledVector(binormal, gb * spread * 0.4);
    positions[i * 3] = pos.x;
    positions[i * 3 + 1] = pos.y;
    positions[i * 3 + 2] = pos.z;

    const closeness = 1 - Math.min(1, Math.abs(g));
    col.copy(colorParticle).lerp(colorCore, closeness * closeness);
    colors[i * 3] = col.r;
    colors[i * 3 + 1] = col.g;
    colors[i * 3 + 2] = col.b;

    sizes[i] =
      (0.35 + Math.random() * Math.random() * 0.9) *
      sizeMax *
      (0.55 + closeness * 0.75);
    phases[i] = Math.random();
  }

  const geo = new THREE.BufferGeometry();
  geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
  geo.setAttribute("aColor", new THREE.BufferAttribute(colors, 3));
  geo.setAttribute("aSize", new THREE.BufferAttribute(sizes, 1));
  geo.setAttribute("aPhase", new THREE.BufferAttribute(phases, 1));
  return geo;
}

const DUST_VERTEX = `
attribute vec3 aColor;
attribute float aSize;
attribute float aPhase;
uniform float uTime;
uniform float uSizeScale;
uniform vec2 uPointerNDC;
uniform float uPointerPresence;
uniform vec3 uFogColor;
uniform float uFogDensity;
varying vec3 vColor;
varying float vSpot;
varying float vTwinkle;
varying float vFog;

void main() {
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
  gl_Position = projectionMatrix * mvPosition;

  float tw = 0.55 + 0.45 * sin(uTime * (0.5 + aPhase * 1.6) + aPhase * 41.0);
  vTwinkle = tw;

  vec2 ndc = gl_Position.xy / max(gl_Position.w, 0.0001);
  float d = distance(ndc, uPointerNDC);
  vSpot = uPointerPresence * exp(-d * d * 9.0);

  vColor = aColor;

  float dist = -mvPosition.z;
  float fogF = exp(-pow(uFogDensity * dist, 2.0));
  vFog = clamp(fogF, 0.0, 1.0);

  float size = aSize * uSizeScale / max(dist, 0.1);
  gl_PointSize = clamp(size * (1.0 + vSpot * 1.6), 1.0, 64.0);
}
`;

const DUST_FRAGMENT = `
precision highp float;
uniform vec3 uFogColor;
uniform float uBoost;
varying vec3 vColor;
varying float vSpot;
varying float vTwinkle;
varying float vFog;

void main() {
  vec2 c = gl_PointCoord - 0.5;
  float d = length(c) * 2.0;
  float alpha = exp(-d * d * 3.4);
  if (alpha < 0.02) discard;
  vec3 col = (vColor * (0.65 + vTwinkle * 0.55) + vec3(1.0) * vSpot * 0.55) * uBoost;
  col = mix(uFogColor, col, vFog);
  gl_FragColor = vec4(col * alpha, alpha * mix(0.25, 1.0, vFog));
}
`;

const COMET_VERTEX = `
attribute float aSize;
uniform float uSizeScale;
varying float vFog;
uniform float uFogDensity;

void main() {
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
  gl_Position = projectionMatrix * mvPosition;
  float dist = -mvPosition.z;
  vFog = clamp(exp(-pow(uFogDensity * dist, 2.0)), 0.0, 1.0);
  gl_PointSize = clamp(aSize * uSizeScale * 1.6 / max(dist, 0.1), 1.0, 90.0);
}
`;

const COMET_FRAGMENT = `
precision highp float;
uniform vec3 uColor;
uniform vec3 uFogColor;
uniform float uBoost;
varying float vFog;

void main() {
  vec2 c = gl_PointCoord - 0.5;
  float d = length(c) * 2.0;
  float alpha = exp(-d * d * 2.4);
  if (alpha < 0.02) discard;
  vec3 col = mix(uFogColor, uColor * uBoost, vFog);
  gl_FragColor = vec4(col * alpha, alpha * mix(0.2, 1.0, vFog));
}
`;

/* Post-process reading guard — the `holdUnder` luminance ceiling every
   flat-shader field applies per-pixel, run here as a final composite pass
   since this field has no single fragment shader to fold it into. */
const GUARD_SHADER = {
  uniforms: {
    tDiffuse: { value: null as THREE.Texture | null },
    uReadA: { value: new THREE.Vector4(0.5, 0.5, 0.44, 0.32) },
    uGuard: { value: 0 },
  },
  vertexShader: `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    precision highp float;
    uniform sampler2D tDiffuse;
    uniform vec4 uReadA;
    uniform float uGuard;
    varying vec2 vUv;

    vec3 holdUnder(vec3 col, float ymax) {
      float y = dot(pow(max(col, 0.0), vec3(2.2)), vec3(0.2126, 0.7152, 0.0722));
      return y > ymax ? col * pow(ymax / max(y, 1e-5), 1.0 / 2.2) : col;
    }

    void main() {
      vec4 c = texture2D(tDiffuse, vUv);
      vec2 rd = abs(vUv - uReadA.xy) / max(uReadA.zw, vec2(0.02));
      float md = mix(max(rd.x, rd.y), length(rd), 0.4);
      float band = 1.0 - smoothstep(0.72, 2.1, md);
      gl_FragColor = vec4(mix(c.rgb, holdUnder(c.rgb, 0.12), band * uGuard), c.a);
    }
  `,
};

function buildBackgroundTexture(): THREE.CanvasTexture {
  const w = 1024;
  const h = 576;
  const canvas = document.createElement("canvas");
  canvas.width = w;
  canvas.height = h;
  const ctx = canvas.getContext("2d")!;

  const toCss = (c: readonly [number, number, number], a = 1) =>
    `rgba(${Math.round(c[0] * 255)}, ${Math.round(c[1] * 255)}, ${Math.round(c[2] * 255)}, ${a})`;

  const base = ctx.createLinearGradient(0, h, 0, 0);
  base.addColorStop(0, toCss(PALETTE.deep));
  base.addColorStop(0.55, toCss(PALETTE.navy));
  base.addColorStop(1, toCss(PALETTE.navy));
  ctx.fillStyle = base;
  ctx.fillRect(0, 0, w, h);

  const cx = w * 0.46;
  const cy = h * 0.3;
  const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, w * 0.4);
  glow.addColorStop(0, toCss(PALETTE.glowCore, 0.85));
  glow.addColorStop(0.18, toCss(PALETTE.glowHi, 0.5));
  glow.addColorStop(0.5, toCss(PALETTE.glowHi, 0.16));
  glow.addColorStop(1, toCss(PALETTE.glowHi, 0));
  ctx.fillStyle = glow;
  ctx.fillRect(0, 0, w, h);

  const vig = ctx.createRadialGradient(
    w * 0.5,
    h * 0.55,
    h * 0.35,
    w * 0.5,
    h * 0.55,
    h * 0.95
  );
  vig.addColorStop(0, "rgba(0,0,0,0)");
  vig.addColorStop(1, toCss(PALETTE.deep, 0.55));
  ctx.fillStyle = vig;
  ctx.fillRect(0, 0, w, h);

  const tex = new THREE.CanvasTexture(canvas);
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}

interface MountOptions {
  guardSelector?: string | null;
  onPainted?: () => void;
  onIdle?: () => void;
}

function mountFiberDrift(canvas: HTMLCanvasElement, opts: MountOptions): () => void {
  const { guardSelector, onPainted, onIdle } = opts;

  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  const root = document.documentElement;
  const isStill = () =>
    motionQuery.matches || root.dataset.forceReducedMotion === "on";

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

  let renderer: THREE.WebGLRenderer | null = null;
  let composer: EffectComposer | null = null;
  let bloomPass: UnrealBloomPass | null = null;
  let guardPass: ShaderPass | null = null;
  let scene: THREE.Scene | null = null;
  let camera: THREE.PerspectiveCamera | null = null;
  let rig: THREE.Group | null = null;
  let dustMaterial: THREE.ShaderMaterial | null = null;
  let cometMaterial: THREE.ShaderMaterial | null = null;
  let cometGeometry: THREE.BufferGeometry | null = null;
  let cometPositions: Float32Array | null = null;
  let strands: Strand[] = [];
  let bgTexture: THREE.CanvasTexture | null = null;
  const disposables: { dispose: () => void }[] = [];

  let started = false;
  let dead = false;
  let visible = false;
  let painted = false;
  let raf = 0;
  let start = 0;
  let last = 0;
  const stillTime = 6;

  let width = 0;
  let height = 0;

  /* Pointer — local to this field, window-scoped, eased. */
  let pointerRawX = 0;
  let pointerRawY = 0;
  let pointerRawLive = false;
  const onPointerMove = (e: PointerEvent) => {
    pointerRawX = e.clientX;
    pointerRawY = e.clientY;
    pointerRawLive = true;
  };
  const onPointerGone = () => {
    pointerRawLive = false;
  };
  window.addEventListener("pointermove", onPointerMove, { passive: true });
  window.addEventListener("pointerdown", onPointerMove, { passive: true });
  window.addEventListener("pointerup", onPointerGone, { passive: true });
  window.addEventListener("pointercancel", onPointerGone, { passive: true });
  document.addEventListener("pointerleave", onPointerGone);
  window.addEventListener("blur", onPointerGone);

  let ndcX = 0;
  let ndcY = 0;
  let presence = 0;
  let camYawTarget = 0;
  let camPitchTarget = 0;
  let camYaw = 0;
  let camPitch = 0;
  let idleT = Math.random() * 100;

  const trackPointer = (rect: DOMRect, frozen: boolean) => {
    if (frozen) {
      presence = 0;
      return;
    }
    let target = 0;
    if (pointerRawLive && rect.width > 0 && rect.height > 0) {
      const x = (pointerRawX - rect.left) / rect.width;
      const y = (pointerRawY - rect.top) / rect.height;
      const near = x > -0.25 && x < 1.25 && y > -0.25 && y < 1.25;
      if (near) {
        target = 1;
        ndcX += (x * 2 - 1 - ndcX) * 0.14;
        ndcY += (-(y * 2 - 1) - ndcY) * 0.14;
        camYawTarget = -ndcX * 0.16;
        camPitchTarget = ndcY * 0.09;
      }
    }
    presence += (target - presence) * 0.08;
    if (target === 0) {
      camYawTarget *= 0.96;
      camPitchTarget *= 0.96;
    }
    camYaw += (camYawTarget - camYaw) * 0.06;
    camPitch += (camPitchTarget - camPitch) * 0.06;
  };

  const setup = (): boolean => {
    if (started) return renderer != null;
    started = true;

    try {
      renderer = new THREE.WebGLRenderer({
        canvas,
        antialias: false,
        alpha: false,
        powerPreference: "low-power",
      });
    } catch {
      renderer = null;
    }
    if (!renderer) return false;

    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
    renderer.outputColorSpace = THREE.SRGBColorSpace;
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 1.05;

    scene = new THREE.Scene();
    bgTexture = buildBackgroundTexture();
    scene.background = bgTexture;
    scene.fog = new THREE.FogExp2(srgb(PALETTE.navy), 0.085);

    camera = new THREE.PerspectiveCamera(50, 16 / 9, 0.1, 60);
    camera.position.set(0, 0, 7);

    rig = new THREE.Group();
    scene.add(rig);

    strands = buildStrands();
    const colorCore = srgb(PALETTE.core);
    const colorParticle = srgb(PALETTE.particle);
    const fogColor = srgb(PALETTE.navy);

    dustMaterial = new THREE.ShaderMaterial({
      uniforms: {
        uTime: { value: 0 },
        uSizeScale: { value: 420 },
        uPointerNDC: { value: new THREE.Vector2(2, 2) },
        uPointerPresence: { value: 0 },
        uFogColor: { value: fogColor },
        uFogDensity: { value: 0.085 },
        uBoost: { value: EMISSIVE_BOOST },
      },
      vertexShader: DUST_VERTEX,
      fragmentShader: DUST_FRAGMENT,
      transparent: true,
      depthWrite: false,
      blending: THREE.AdditiveBlending,
    });
    disposables.push(dustMaterial);

    cometMaterial = new THREE.ShaderMaterial({
      uniforms: {
        uSizeScale: { value: 420 },
        uColor: { value: colorCore },
        uFogColor: { value: fogColor },
        uFogDensity: { value: 0.085 },
        uBoost: { value: EMISSIVE_BOOST * 1.15 },
      },
      vertexShader: COMET_VERTEX,
      fragmentShader: COMET_FRAGMENT,
      transparent: true,
      depthWrite: false,
      blending: THREE.AdditiveBlending,
    });
    disposables.push(cometMaterial);

    for (const strand of strands) {
      const tubeRadius = THREE.MathUtils.lerp(0.03, 0.009, strand.t);
      const tubeOpacity = THREE.MathUtils.lerp(0.82, 0.14, strand.t);
      const tint = colorParticle
        .clone()
        .lerp(colorCore, 0.55)
        .multiplyScalar(EMISSIVE_BOOST);
      const tubeGeo = new THREE.TubeGeometry(strand.curve, 200, tubeRadius, 8, false);
      const tubeMat = new THREE.MeshBasicMaterial({
        color: tint,
        transparent: true,
        opacity: tubeOpacity,
        blending: THREE.AdditiveBlending,
        depthWrite: false,
        fog: true,
      });
      disposables.push(tubeGeo, tubeMat);
      rig.add(new THREE.Mesh(tubeGeo, tubeMat));

      const dustGeo = buildDustGeometry(strand, colorCore, colorParticle);
      disposables.push(dustGeo);
      rig.add(new THREE.Points(dustGeo, dustMaterial));
    }

    const cometCount = STRAND_COUNT * COMETS_PER_STRAND;
    cometPositions = new Float32Array(cometCount * 3);
    const cometSizes = new Float32Array(cometCount);
    for (let i = 0; i < cometCount; i++) cometSizes[i] = 0.05 + Math.random() * 0.02;
    cometGeometry = new THREE.BufferGeometry();
    cometGeometry.setAttribute(
      "position",
      new THREE.BufferAttribute(cometPositions, 3)
    );
    cometGeometry.setAttribute("aSize", new THREE.BufferAttribute(cometSizes, 1));
    disposables.push(cometGeometry);
    rig.add(new THREE.Points(cometGeometry, cometMaterial));

    composer = new EffectComposer(renderer);
    composer.addPass(new RenderPass(scene, camera));
    bloomPass = new UnrealBloomPass(new THREE.Vector2(1, 1), 0.85, 0.45, 0.62);
    composer.addPass(bloomPass);
    guardPass = new ShaderPass(GUARD_SHADER);
    guardPass.material.uniforms.uGuard.value = guardOn ? 1 : 0;
    composer.addPass(guardPass);
    composer.addPass(new OutputPass());

    return true;
  };

  const updateComets = (time: number) => {
    if (!cometGeometry || !cometPositions) return;
    const p = new THREE.Vector3();
    let idx = 0;
    for (const strand of strands) {
      for (let c = 0; c < COMETS_PER_STRAND; c++) {
        const raw = time * strand.speed + strand.phase + c / COMETS_PER_STRAND;
        const t = strand.speed >= 0 ? raw - Math.floor(raw) : 1 - (raw - Math.floor(raw));
        strand.curve.getPointAt(THREE.MathUtils.clamp(t, 0, 1), p);
        cometPositions[idx * 3] = p.x;
        cometPositions[idx * 3 + 1] = p.y;
        cometPositions[idx * 3 + 2] = p.z;
        idx++;
      }
    }
    cometGeometry.attributes.position.needsUpdate = true;
  };

  const measure = (): boolean => {
    if (!renderer || !camera || !composer) return false;
    const rect = canvas.getBoundingClientRect();
    if (rect.width === 0 || rect.height === 0) return false;
    const w = Math.round(rect.width);
    const h = Math.round(rect.height);
    if (w !== width || h !== height) {
      width = w;
      height = h;
      renderer.setSize(w, h, false);
      composer.setSize(w, h);
      bloomPass?.setSize(Math.round(w / 2), Math.round(h / 2));
      camera.aspect = w / h;
      camera.updateProjectionMatrix();
    }
    return true;
  };

  const render = (time: number, frozen: boolean) => {
    if (!renderer || !camera || !composer || !rig || !dustMaterial) return;
    const rect = canvas.getBoundingClientRect();
    trackPointer(rect, frozen);

    if (!frozen) {
      idleT += 0.006;
      rig.rotation.y = camYaw * -1 + Math.sin(idleT) * 0.012;
      rig.rotation.x = camPitch * -1 + Math.sin(idleT * 0.7) * 0.006;
      camera.rotation.y = camYaw;
      camera.rotation.x = camPitch;
      updateComets(time);
    }

    dustMaterial.uniforms.uTime.value = frozen ? stillTime : time;
    dustMaterial.uniforms.uPointerNDC.value.set(
      frozen ? 2 : ndcX,
      frozen ? 2 : ndcY
    );
    dustMaterial.uniforms.uPointerPresence.value = frozen ? 0 : presence;

    if (guardPass && guardOn) {
      const el = readGuardEl();
      let cx = 0.5, cy = 0.5, hw = 0.44, hh = 0.32;
      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;
      }
      guardPass.material.uniforms.uReadA.value.set(cx, cy, hw, hh);
    }

    composer.render();
    if (!painted) {
      painted = true;
      onPainted?.();
    }
  };

  const drawStill = () => {
    if (!measure()) return;
    render(stillTime, true);
  };

  const frame = (now: number) => {
    raf = requestAnimationFrame(frame);
    if (now - last < 1000 / 30) return;
    last = now;
    if (!measure()) return;
    render((now - start) / 1000, false);
  };

  const stop = () => {
    if (raf) cancelAnimationFrame(raf);
    raf = 0;
    if (painted) {
      painted = false;
      onIdle?.();
    }
  };

  const play = () => {
    if (dead || !visible || document.hidden) return;
    if (!started) {
      if (!setup()) return;
    }
    if (isStill()) {
      stop();
      drawStill();
      return;
    }
    if (raf) return;
    start = performance.now() - stillTime * 1000;
    last = 0;
    if (measure()) {
      render((performance.now() - start) / 1000, false);
      last = performance.now();
    }
    raf = requestAnimationFrame(frame);
  };

  const io = new IntersectionObserver(
    ([entry]) => {
      visible = entry.isIntersecting;
      if (visible) play();
      else stop();
    },
    { rootMargin: "160px" }
  );
  io.observe(canvas);

  const onVisibility = () => (document.hidden ? stop() : play());
  document.addEventListener("visibilitychange", onVisibility);

  const ro = new ResizeObserver(() => {
    if (isStill()) drawStill();
  });
  ro.observe(canvas);

  const restart = () => {
    stop();
    play();
  };
  const mo = new MutationObserver(restart);
  mo.observe(root, {
    attributes: true,
    attributeFilter: ["data-force-reduced-motion"],
  });
  motionQuery.addEventListener("change", restart);

  const onContextLost = (e: Event) => {
    e.preventDefault();
    dead = true;
    stop();
    painted = false;
    onIdle?.();
  };
  const onContextRestored = () => {
    dead = false;
    play();
  };
  canvas.addEventListener("webglcontextlost", onContextLost);
  canvas.addEventListener("webglcontextrestored", onContextRestored);

  return () => {
    stop();
    io.disconnect();
    ro.disconnect();
    mo.disconnect();
    motionQuery.removeEventListener("change", restart);
    document.removeEventListener("visibilitychange", onVisibility);
    canvas.removeEventListener("webglcontextlost", onContextLost);
    canvas.removeEventListener("webglcontextrestored", onContextRestored);
    window.removeEventListener("pointermove", onPointerMove);
    window.removeEventListener("pointerdown", onPointerMove);
    window.removeEventListener("pointerup", onPointerGone);
    window.removeEventListener("pointercancel", onPointerGone);
    document.removeEventListener("pointerleave", onPointerGone);
    window.removeEventListener("blur", onPointerGone);
    for (const d of disposables) d.dispose();
    bgTexture?.dispose();
    composer = null;
    if (renderer) {
      renderer.dispose();
      renderer.forceContextLoss();
    }
  };
}

export interface FiberDriftFieldProps {
  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 FiberDriftField({
  className,
  guardSelector = null,
}: FiberDriftFieldProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

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

    return mountFiberDrift(canvas, {
      guardSelector,
      onPainted: () => canvas.setAttribute("data-shader", "on"),
      onIdle: () => canvas.removeAttribute("data-shader"),
    });
  }, [guardSelector]);

  return (
    <div
      className={`absolute inset-0 overflow-hidden${className ? ` ${className}` : ""}`}
      aria-hidden="true"
    >
      <div className="absolute inset-0 [background:radial-gradient(_125%_135%_at_45%_172%,transparent_44%,oklch(0.86_0.06_235_/_0.55)_51%,oklch(0.72_0.12_235_/_0.4)_57%,transparent_64%_),radial-gradient(_48%_58%_at_42%_6%,oklch(0.78_0.08_235_/_0.6)_0%,transparent_66%_),radial-gradient(_60%_70%_at_45%_60%,oklch(0.32_0.09_245)_0%,oklch(0.17_0.06_255)_68%,oklch(0.08_0.03_255)_100%_)]" />
      <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