Skip to content

IrisBackgroundsHorizon

Horizon

A giant circle sits just off the bottom edge — only its upper rim curves into view, bright where the rim itself falls, dark above and below it.

glow

Horizon

Reverse-measured, pixel by pixel, off one reference screenshot rather than guessed: the rim's on-screen curve, both falloff widths, the left/right dimming and the five colour stops are all sampled straight off the source PNG outside its text boxes. The shape is a ring, not a blob — every pixel's distance to a circle centred just below the bottom edge is compared against that circle's radius, and the glow is a gaussian of the difference, so brightness peaks exactly on the rim rather than at a point. The falloff either side is asymmetric — tight above the rim so the top of the frame reads near-black, a little wider below it — and the rim itself dims toward the left and right edges, the way a lit sphere's rim falls away from the point facing the viewer.

The rim leans a few percent toward the cursor and tips its height slightly, eased, plus a small bloom rides the pointer directly; with no pointer it idles on a slow, barely-there drift. Fixed palette, not the token ramp — the measured hottest pixel is a pale sky blue, not white, and that exact horizon lives nowhere in the site's own accent ramp, so the CSS floor carries the same colours as a wide, flat radial gradient.

  • cool
  • glow
  • pointer-driven
Family
glow
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

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

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">
      <HorizonField />
    </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 reverse-measured, pixel by pixel, off one reference
 * screenshot: a near-black frame with a huge circle whose centre sits just
 * *below* the bottom edge, so only its upper rim curves into view — bright
 * where the rim itself falls, fading fast above it (the nav reads
 * near-black) and rather faster below it too (a real gaussian ring, not a
 * blob glow rising from the centre the way `EmberRidgeField` works).
 *
 * The geometry (`u_center`/`u_radius`) and both falloff widths were fit by
 * sampling the source PNG outside its text boxes: distance from a fitted
 * circle predicts the rim's on-screen path to within a couple of pixels
 * across the whole width, confirming it really is one circle, not a warped
 * curve. Brightness along that rim isn't uniform, though — it tapers off
 * toward the left/right edges (`u_taper`), the way a lit sphere's rim would
 * naturally dim away from the point facing the viewer. The five palette
 * stops are the actual measured colours at five brightness levels of that
 * same reverse-fit, not a guessed ramp — the hottest measured pixel in the
 * source lands at (185, 233, 246), a pale sky blue, never pure white.
 *
 * Fixed palette, not the token ramp — same reasoning as `EmberRidgeField`:
 * this is one specific reference's blue-white horizon, not a themeable
 * accent. The CSS floor below approximates the same rim as a wide, flat
 * radial gradient, so no-WebGL, a lost context, a hidden tab or
 * `prefers-reduced-motion` all leave a close composition on screen.
 *
 * The pointer leans the rim a few percent horizontally and tips its height
 * slightly, eased, plus drops a small bloom of its own — the reference has
 * no pointer of its own to match, so this stays a light touch. With no
 * pointer the rim idles on a slow, barely-there drift.
 *
 * Drop it into any `position: relative`/`isolate` parent — it fills the
 * box. Reading guard: when `guardSelector` resolves to an element, the
 * field clamps its own luminance under a ceiling inside that region every
 * frame (hue untouched), so copy laid over it holds contrast. `null` (the
 * default) turns the guard off.
 */

const PALETTE: Record<string, [number, number, number]> = {
  u_ground: [0.0039, 0.0392, 0.0667], // t=0.00 — measured background
  u_deep: [0.0941, 0.2627, 0.3961], // t=0.25
  u_blue: [0.1804, 0.5451, 0.7333], // t=0.50
  u_cyan: [0.4824, 0.7843, 0.9098], // t=0.75
  u_hot: [0.7255, 0.9137, 0.9647], // t=1.00 — the actual brightest pixel measured
};

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;   /* t=0.00 */
uniform vec3 u_deep;     /* t=0.25 */
uniform vec3 u_blue;     /* t=0.50 */
uniform vec3 u_cyan;     /* t=0.75 */
uniform vec3 u_hot;      /* t=1.00, right at the rim's brightest point */
uniform float u_speed;   /* idle drift speed */

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 */
  float aspect = res.x / max(res.y, 1.0);
  vec2 P = (uv - 0.5) * vec2(aspect, 1.0);

  /* a huge circle centred just below the bottom edge; only its upper rim
     ever enters view. the pointer leans it a little and tips the rim height */
  float t = u_time * u_speed;
  vec2 drift = vec2(sin(t * 0.6), cos(t * 0.5)) * 0.006;
  vec2 lean = vec2(
    (u_pointer.x - 0.5) * aspect * 0.05,
    (u_pointer.y - 0.5) * 0.02
  ) * u_pointer.z;
  vec2 center = vec2(0.0, -0.742) + drift + lean;
  float radius = 1.01;

  float dist = distance(P, center);
  float ringDist = dist - radius;

  /* asymmetric gaussian ring, both widths fit from the reference: a tight
     fade above the rim (into the nav band) and a slightly wider one below
     it (the wash the reference carries a little way down the page) */
  float sigma = ringDist > 0.0 ? 0.04 : 0.085;
  float ring = exp(-(ringDist * ringDist) / (2.0 * sigma * sigma));

  /* the rim dims toward the left/right edges, like a lit sphere's rim
     falling away from the point facing the viewer */
  float taper = exp(-(P.x * P.x) / (2.0 * 0.677 * 0.677));
  float g = ring * taper;

  /* the measured five-stop ramp: ground, deep, blue, cyan, hot at
     t = 0, 0.25, 0.5, 0.75, 1.0 */
  vec3 col = u_ground;
  col = mix(col, u_deep, smoothstep(0.0, 0.35, g));
  col = mix(col, u_blue, smoothstep(0.3, 0.6, g));
  col = mix(col, u_cyan, smoothstep(0.55, 0.85, g));
  col = mix(col, u_hot, smoothstep(0.8, 1.05, g));

  /* a small bloom that rides the pointer directly, on top of the base rim */
  vec2 pd = P - (u_pointer.xy - 0.5) * vec2(aspect, 1.0);
  col += u_cyan * u_pointer.z * exp(-dot(pd, pd) * 7.0) * 0.06;

  col = max(col, 0.0);

  /* ---- the reading guard ---- */
  vec2 rd = abs(uv - u_readA.xy) / max(u_readA.zw, vec2(0.02));
  float m = mix(max(rd.x, rd.y), length(rd), 0.4);
  float guardBand = 1.0 - smoothstep(0.72, 2.1, m);
  col = mix(col, holdUnder(col, 0.18), guardBand * u_guard);

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

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

export interface HorizonFieldProps {
  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;
  /** Idle drift speed; 0 freezes the rim in place. */
  speed?: number;
}

export function HorizonField({
  className,
  guardSelector = null,
  speed = 0.05,
}: HorizonFieldProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  const values = { speed };
  const valuesRef = useRef(values);
  valuesRef.current = values;

  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_speed", "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_speed) gl.uniform1f(u.u_speed, valuesRef.current.speed);
        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 (u.u_speed) gl.uniform1f(u.u_speed, valuesRef.current.speed);
        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"),
      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(_130%_46%_at_50%_23%,#b9e9f6_0%,#7bc8e8_14%,#2e8bbb_30%,#184365_46%,#010a11_64%,#010a11_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