Skip to content

IrisBackgroundsVortex Well

Vortex Well

A log-spiral of violet-and-teal light winding down into a hot core, pointer-steered.

vortex

Vortex Well

A constant-pitch spiral rather than a set of closing rings — the arms recede toward the centre forever, the way water actually winds down a drain instead of tiling in flat bands. Each arm shimmers with a little procedural noise as it winds, alternating deep violet-magenta and teal, and brightens back up right where it spirals into a hot near-white core. One slow breathing ring sits at the rim, throwing off a stray warm-amber glint the way a real vortex kicks up foam at its edge.

The cursor takes hold of the whole thing rather than just lighting it: x sets which way it spins and how fast, y sets how tightly the arms wind, and the centre itself leans a little toward wherever the pointer sits. Move away and it settles back into a slow, self-sustaining drift and spin.

  • cool-warm
  • pointer-driven
  • spiral
Family
vortex
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

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

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">
      <VortexField />
    </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 vortex — a log-spiral of light winding down into a hot core
 * over a near-black ground, the way water spirals into a drain rather than
 * closing into flat rings. The arms recede toward the centre forever (a
 * constant-pitch spiral, not a repeating ring pattern), each one shimmering
 * with a little procedural noise so the winding never reads as a static
 * texture. One slow breathing ring throws off a stray warm glint at the
 * rim, the way a real vortex kicks up foam at its edge.
 *
 * The cursor is a hand on the whole thing: x sets which way it spins and
 * how fast, y sets how tightly the arms wind, and the centre itself leans
 * a little toward wherever the pointer sits. With no pointer it drifts and
 * spins gently on its own rather than sitting still.
 *
 * 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-vortexfield__floor`
 * underneath visible — a still spiral in the same palette, never a blank box.
 *
 * Like `HaloRingField` and `OrbitalArcField`, the palette is a fixed set
 * passed as uniforms rather than read from the accent ramp — this violet/
 * teal vortex is its own mood, not the portfolio's amber.
 *
 * 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.007, 0.006, 0.017], // near-black, cool violet
  u_armA: [0.37, 0.09, 0.56], // one spiral arm colour, deep violet-magenta
  u_armB: [0.06, 0.52, 0.58], // the other, teal
  u_core: [0.95, 0.97, 1.0], // the hot near-white centre the arms wind into
  u_ember: [1.0, 0.56, 0.22], // the stray warm glint on the rim ring
};

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_armA;
uniform vec3 u_armB;
uniform vec3 u_core;
uniform vec3 u_ember;

uniform vec4  u_readA;
uniform float u_guard;

void main() {
  vec2 res = u_res / u_scale;
  vec2 uv  = gl_FragCoord.xy / u_scale / res;              /* 0..1, y up */
  vec2 sc  = vec2(res.x / max(res.y, 1.0), 1.0);
  vec2 q   = uv * sc;

  float pres = u_pointer.z;
  vec2  ptr  = (u_pointer.xy - 0.5) * 2.0;                  /* -1..1 */

  /* the vortex drifts a little on its own, and leans toward the cursor
     when one is present, rather than snapping straight to it */
  vec2 idle   = vec2(sin(u_time * 0.03), cos(u_time * 0.021)) * 0.02;
  vec2 centre = vec2(0.5 * sc.x, 0.52) + idle + ptr * 0.07 * pres;
  vec2 p = q - centre;

  float r   = max(length(p), 1e-4);
  float ang = atan(p.y, p.x);

  /* a log-spiral: constant-pitch arms that recede toward the centre
     forever rather than closing into rings, the way water actually winds
     down a drain. Spin direction and how tightly it winds both answer to
     the cursor; with no pointer it just spins on its own. */
  float spin   = u_time * 0.16 + ptr.x * 1.35 * pres;
  float twist  = 2.6 + ptr.y * 1.5 * pres;
  float warped = ang + log(r) * twist - spin;

  const float NARMS = 3.0;
  float shimmer = fbm(vec2(warped * 0.6, r * 3.0 - u_time * 0.05)) * 0.35;
  float arm  = sin(warped * NARMS + shimmer);
  float band = smoothstep(-0.15, 0.55, arm);

  /* the arms fall off toward the rim rather than tiling forever, and
     brighten again right at the core */
  float fall   = exp(-r * 1.55);
  vec3  armCol = mix(u_armA, u_armB, 0.5 + 0.5 * sin(warped * NARMS - 1.4));

  vec3 col = u_ground;
  col += armCol * band * fall * (1.15 + 0.5 * pres);

  /* the hot core the arms spiral into */
  float core = exp(-r * r / (0.05 * 0.05));
  col += u_core * core;
  col += mix(u_armA, u_armB, 0.5) * exp(-r * r / (0.16 * 0.16)) * 0.35;

  /* one slow breathing ring, a stray warm glint the way a real vortex
     throws off a bit of foam at its rim */
  float ringR = 0.66 + 0.03 * sin(u_time * 0.22);
  float ring  = exp(-pow(r - ringR, 2.0) / (0.012 * 0.012));
  col += u_ember * ring * 0.55;

  col *= mix(1.0, 0.72, smoothstep(0.75, 1.35, r));
  col = max(col, 0.0);

  /* ---- the reading guard (see TileField for the full rationale) ---- */
  vec2 rg = abs(uv - u_readA.xy) / max(u_readA.zw, vec2(0.02));
  float m = mix(max(rg.x, rg.y), length(rg), 0.4);
  float guardBand = 1.0 - smoothstep(0.72, 2.1, m);
  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 VortexFieldProps {
  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 VortexField({ className, guardSelector = null }: VortexFieldProps) {
  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 HaloRingField / CoilField: once
         painted, the last frame stays on screen while the surface is parked
         off-view. onLost drops back to the CSS floor. */
      onLost: () => canvas.removeAttribute("data-shader"),
      maxPixels: 1_800_000,
      dprCap: 1.5,
    });
  }, [guardSelector]);

  return (
    <div className={`absolute inset-0 overflow-hidden${className ? ` ${className}` : ""}`} aria-hidden="true">
      <div className="absolute inset-0 [background:oklch(0.03_0.015_300)] before:content-[''] before:absolute before:[inset:42%_auto_auto_50%] before:[width:min(78vh,92%)] before:[aspect-ratio:1_/_1] before:[transform:translate(-50%,-50%)] before:[border-radius:50%] before:[background:conic-gradient(_from_0deg,oklch(0.42_0.19_320_/_0.75)_0deg,oklch(0.5_0.15_195_/_0.75)_60deg,oklch(0.42_0.19_320_/_0.75)_120deg,oklch(0.5_0.15_195_/_0.75)_180deg,oklch(0.42_0.19_320_/_0.75)_240deg,oklch(0.5_0.15_195_/_0.75)_300deg,oklch(0.42_0.19_320_/_0.75)_360deg_)] before:[-webkit-mask-image:radial-gradient(_circle,transparent_0%,#000_22%,#000_68%,transparent_78%_)] before:[mask-image:radial-gradient(_circle,transparent_0%,#000_22%,#000_68%,transparent_78%_)] before:[filter:blur(6px)] after:content-[''] after:absolute after:inset-0 after:[background:radial-gradient(_10%_9%_at_50%_42%,oklch(0.97_0.01_250_/_0.9)_0%,transparent_100%_),radial-gradient(_60%_24%_at_50%_74%,oklch(0.68_0.14_55_/_0.35)_0%,transparent_70%_)]" />
      <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