Skip to content

IrisBackgroundsNova

Nova

Seven huge ellipses sharing one left-of-centre vanishing point, each drawing a symmetric pair of violet-to-cyan trails that bow out into broad sweeping arcs — a real perspective system, not independent lines.

burst

Nova

Every trail's geometry comes off the same shared point: seven ellipses, from a tight bright one to a huge dim one, all with their own left vertex sitting on — or a touch past — that single spot. Each ellipse draws its own upper and lower arc at once, so the seven read as fourteen sweeping trails, tight and near-white close to the point and bowing out into wide, increasingly separated arcs across the rest of the frame, the innermost cyan and closest to the burst, the outermost violet and fading toward the ground. The point itself carries its own brighter burst — a white core, a cyan halo, a soft wide bloom, and a small horizontal lens flare — held well above any single trail, so the frame has one obvious centre rather than an even wash.

Motion is deliberately small: each trail's width and brightness breathes on its own slow phase, nothing travels or waves. The cursor takes hold of the vanishing point itself, cinching the whole burst toward it and brightening it, easing back once the pointer moves away.

  • cool
  • pointer-driven
  • perspective
  • lens-flare
Family
burst
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

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

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">
      <NovaField />
    </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 built around one shared perspective system: a single
 * vanishing point sitting left-of-centre, and seven huge ellipses whose
 * left vertex all sit at (or a touch left of) that same point. Each ellipse
 * draws both its own upper and lower arc at once — so seven `Trail`s read
 * as fourteen sweeping light trails, tight and bright near the point,
 * bowing out wide across the rest of the frame the way a photograph of
 * light trails on a curved road reads, never independent horizontal lines.
 * The geometry is the whole effect: nothing here is drawn as a bent line
 * with two straight legs, everything comes out of the ellipse's own
 * implicit equation and an analytic distance to it.
 *
 * Every trail carries three glow layers — a hairline core, a soft inner
 * blur, a wide low-opacity halo — each a Gaussian falloff off that same
 * analytic distance, and its colour comes off a shared five-stop gradient
 * (deep violet → violet → magenta → electric blue → cyan) rather than one
 * flat hue per line, brightening toward white the closer a pixel sits to
 * the vanishing point. The point itself carries its own brighter burst: a
 * white core, a cyan halo, a soft wide bloom, and a small horizontal lens
 * flare — held well above anything a single trail reaches, so the frame
 * has one obvious centre of gravity instead of an even wash.
 *
 * Motion is deliberately tiny: each trail's width and intensity breathes on
 * its own slow phase, and the whole burst cinches a little toward the
 * pointer the way leaning into the lens would pull the convergence taut.
 * Nothing waves or travels — the composition stays exactly as legible
 * moving as it is still.
 *
 * One of the reusable background fields (`SlipstreamField`, `CoronaField`,
 * …). 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-novafield__floor`
 * underneath visible — a still frame in the same palette, never a blank
 * box.
 *
 * Like `SlipstreamField` / `CoronaField`, the palette is a fixed
 * violet/magenta/blue/cyan set passed as uniforms rather than read from the
 * accent ramp — these saturated hues 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.008, 0.011, 0.065], // near-black navy, the reference's own #020316
  u_violetDeep: [0.14, 0.035, 0.4], // the outermost, farthest trails
  u_violet: [0.34, 0.08, 0.74],
  u_magenta: [0.7, 0.12, 0.8],
  u_blue: [0.16, 0.35, 0.97],
  u_cyan: [0.3, 0.8, 1.0], // the innermost trails, closest to the burst
  u_white: [0.96, 0.98, 1.0], // the vanishing point's own core
};

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_violetDeep;
uniform vec3 u_violet;
uniform vec3 u_magenta;
uniform vec3 u_blue;
uniform vec3 u_cyan;
uniform vec3 u_white;

uniform vec4  u_readA;
uniform float u_guard;

struct Trail {
  float vertexX;    /* where this ellipse's left vertex sits, relative to
                        the vanishing point — 0 sits exactly on it, a small
                        negative value lets a few trails poke past it and
                        into the left edge instead of a knife-clean point */
  float a;          /* semi-major axis: how far right the loop reaches */
  float b;          /* semi-minor axis: how wide the loop opens, top+bottom */
  float yOff;        /* small vertical bias off the point, for irregularity */
  float thickness;  /* inverse line width */
  float brightness;
  float colorT;     /* position along the shared five-stop gradient */
  float phase;
};

/* deep violet -> violet -> magenta -> electric blue -> cyan: colour is
   distributed along the family rather than one flat hue per trail. */
vec3 palette(float t) {
  vec3 c = mix(u_violetDeep, u_violet, smoothstep(0.0, 0.26, t));
  c = mix(c, u_magenta, smoothstep(0.22, 0.5, t));
  c = mix(c, u_blue, smoothstep(0.46, 0.74, t));
  c = mix(c, u_cyan, smoothstep(0.68, 1.0, t));
  return c;
}

/* Analytic distance to one ellipse's boundary (f / |grad f|), cheap and
   smooth right at the curve, which is all the Gaussian glow layers need.
   Centring the ellipse at (vertexX + a, yOff) puts its own left vertex at
   x = vertexX — every trail's vertex sits on (or a touch left of) the same
   vanishing point, which is the entire perspective system: one shared
   point, seven ellipses reaching out from it, not seven independent lines. */
float trailDist(vec2 p, Trail tr) {
  float cu = p.x - tr.vertexX - tr.a;
  float cv = p.y - tr.yOff;
  float fx = cu / tr.a;
  float fy = cv / tr.b;
  float f = fx * fx + fy * fy - 1.0;
  vec2 grad = vec2(2.0 * cu / (tr.a * tr.a), 2.0 * cv / (tr.b * tr.b));
  return abs(f) / (length(grad) + 1e-4);
}

void addTrail(inout vec3 col, inout float hot, vec2 p, float whiten, Trail tr) {
  float dist = trailDist(p, tr);

  /* gentle breathing, not travel: a slow, tiny modulation of the line's
     own width and brightness, never a moving highlight */
  float breathe = 0.94 + 0.06 * sin(u_time * 0.07 + tr.phase);
  float k = tr.thickness * breathe;

  float core  = exp(-dist * dist * k * 42000.0);   /* hairline */
  float inner = exp(-dist * dist * k * 3600.0);    /* soft blur */
  float outer = exp(-dist * dist * k * 320.0);     /* wide low halo */

  vec3 tint = mix(palette(tr.colorT), u_white, whiten * 0.92);
  float glow = core * 1.15 + inner * 0.4 + outer * 0.14;

  col += tint * glow * tr.brightness;
  hot += core * tr.brightness * 0.5;
}

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 sc = vec2(aspect, 1.0);
  vec2 q  = uv * sc;

  /* the one vanishing point: ~30% of width, ~53% down from the top (uv is
     y-up, so that's uv.y = 0.47) — cinched a little toward the pointer the
     way leaning into the lens pulls the convergence taut, the point itself
     otherwise holding still */
  vec2 vpHome = vec2(0.3 * aspect, 0.47);
  vec2 ptr = u_pointer.xy * sc;
  vec2 toP = vpHome - ptr;
  float grip = u_pointer.z * exp(-dot(toP, toP) * 2.4);
  vec2 vp = vpHome - toP * grip * 0.22;

  vec2 p = q - vp;
  float rVp = length(p);
  float whiten = exp(-rVp * rVp * 11.0);

  vec3 col = u_ground;

  /* a very soft, wide atmosphere right around the point — not a general
     brightening of the frame, just the air nearest the convergence */
  col += mix(u_violetDeep, u_blue, 0.5) * exp(-rVp * rVp * 1.1) * 0.09;

  float hot = 0.0;

  /* seven ellipses, innermost (tight, bright, cyan) to outermost (huge,
     dim, violet, fading into the ground) — each one a matched pair of
     arcs, deliberately uneven in spacing, reach and vertex position */
  addTrail(col, hot, p, whiten, Trail(0.0,    1.05, 0.095, 0.006,  1.5,  1.3,  0.94, 0.4));
  addTrail(col, hot, p, whiten, Trail(-0.008, 1.32, 0.155, -0.014, 1.25, 1.15, 0.80, 1.1));
  addTrail(col, hot, p, whiten, Trail(-0.016, 1.58, 0.24,  0.022,  1.0,  1.02, 0.62, 1.8));
  addTrail(col, hot, p, whiten, Trail(0.0,    1.88, 0.36,  -0.028, 0.84, 0.94, 0.46, 2.4));
  addTrail(col, hot, p, whiten, Trail(-0.03,  2.22, 0.52,  0.032,  0.68, 0.86, 0.30, 3.0));
  addTrail(col, hot, p, whiten, Trail(-0.014, 2.58, 0.70,  -0.018, 0.54, 0.76, 0.15, 3.6));
  addTrail(col, hot, p, whiten, Trail(-0.055, 3.05, 0.92,  0.012,  0.4,  0.64, 0.04, 4.2));

  col += u_white * hot * 0.5;

  /* the vanishing point's own burst: white core, cyan halo, a soft wide
     bloom, and a small horizontal lens flare — held far brighter than any
     single trail, so the centre never reads as a generic glowing orb */
  float coreDot  = exp(-rVp * rVp * 190.0);
  float haloCyan = exp(-rVp * rVp * 60.0);
  float bloom    = exp(-rVp * rVp * 20.0);
  col += u_cyan * haloCyan * 0.5;
  col += mix(u_cyan, u_white, 0.65) * bloom * 0.42;
  col += u_white * coreDot * 1.65;

  float ax = abs(p.x);
  float ay = abs(p.y);
  float d45a = abs(p.x - p.y) * 0.7071;
  float d45b = abs(p.x + p.y) * 0.7071;
  float streak  = exp(-ay * ay * 1100.0) * exp(-ax * ax * 2.6);
  float spikeV  = exp(-ax * ax * 3200.0) * exp(-ay * ay * 16.0);
  float spikeD1 = exp(-d45a * d45a * 2000.0) * exp(-rVp * 4.2);
  float spikeD2 = exp(-d45b * d45b * 2000.0) * exp(-rVp * 4.2);
  col += u_white * (streak * 0.6 + spikeV * 0.22 + spikeD1 * 0.14 + spikeD2 * 0.14);

  /* two faint sparkle points riding the same horizontal axis further out,
     the way a real lens throws small secondary glints along its
     brightest line */
  vec2 g1 = p - vec2(0.22 * aspect, 0.0);
  vec2 g2 = p - vec2(0.4 * aspect, 0.0);
  col += u_cyan   * exp(-dot(g1, g1) * 900.0)  * 0.14;
  col += u_violet * exp(-dot(g2, g2) * 1300.0) * 0.1;

  /* the pointer's own grip: a little extra light right at the point */
  col += u_white * grip * 0.25;

  /* corner vignette */
  vec2 corner = uv - 0.5;
  float cvig = length(corner * vec2(1.0, 1.2));
  col *= mix(1.0, 0.62, smoothstep(0.55, 1.08, cvig));

  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 NovaFieldProps {
  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 NovaField({ className, guardSelector = null }: NovaFieldProps) {
  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 SlipstreamField / CoronaField: 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_000_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(_30%_46%_at_34%_50%,oklch(0.98_0.01_240)_0%,oklch(0.85_0.14_220_/_0.9)_14%,oklch(0.62_0.22_290_/_0.55)_34%,oklch(0.4_0.18_300_/_0.3)_55%,transparent_74%_),radial-gradient(_60%_70%_at_4%_10%,oklch(0.32_0.2_300_/_0.5)_0%,transparent_60%_),radial-gradient(_55%_65%_at_4%_90%,oklch(0.32_0.2_300_/_0.5)_0%,transparent_58%_),radial-gradient(_45%_40%_at_96%_50%,oklch(0.4_0.18_250_/_0.3)_0%,transparent_62%_),oklch(0.05_0.02_260)] after:content-[''] after:absolute after:inset-0 after:[background:repeating-linear-gradient(_0deg,transparent_0_8px,oklch(0.7_0.16_260_/_0.14)_8px_10px_)] after:[-webkit-mask-image:radial-gradient(_55%_75%_at_34%_50%,transparent_6%,#000_40%,transparent_86%_)] after:[mask-image:radial-gradient(_55%_75%_at_34%_50%,transparent_6%,#000_40%,transparent_86%_)]" />
      <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