Skip to content

IrisBackgroundsSolar Fan

Solar Fan

Warm rays fan up from a point pinned outside the lower-left corner — cream at the edges, ember at the core, every edge its own soft bend.

fan

Solar Fan

Built as a fan, not a sunburst: a radiating angle field from an off-canvas origin, the same idea a vector-drawn hero sunburst uses, except no two ray edges share the same curve — each carries its own low-frequency fbm warp, so the fan reads as something photographed through glass rather than drawn with a protractor. Cream at the far corners, coral through the mid-radius, an ember core where the rays converge.

The pointer doesn't pan the fan, it bends it: rays near the cursor get pushed off their resting angle by a field keyed to screen-space distance from the pointer, not distance from the origin, so the warp travels with the cursor instead of rotating the whole piece. The origin itself also leans a few percent toward the cursor, eased, and a small warm halo rides the cursor directly. A light-ground field, same inverse guard contract as `amber-glow`: it washes toward cream for dark copy laid over it.

  • warm
  • light-ground
  • pointer-driven
Family
fan
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

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

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">
      <SolarFanField />
    </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 field of warm rays fanning up from a point pinned just outside the
 * lower-left corner — cream at the corners, coral at mid-radius, an ember
 * core where the rays converge. Unlike a vector sunburst (straight edges,
 * a hard duty cycle), every ray edge carries its own low-frequency fbm
 * warp, so no two rays share exactly the same bend and the whole fan reads
 * as something photographed through glass rather than drawn with a
 * protractor.
 *
 * The pointer doesn't just pan the fan — it bends it. Rays near the cursor
 * get pushed off their resting angle by a localized field (screen-space
 * distance to the pointer, not distance from the origin), so the fan warps
 * around wherever you are rather than sliding as a rigid unit. The origin
 * itself also leans a few percent toward the cursor, eased, and a small
 * warm halo rides the cursor directly. Everything relaxes back to a slow
 * idle sway the moment the pointer leaves.
 *
 * Light-ground field, same inverse contract as `opal-wash`/`amber-glow`:
 * the reading guard washes *toward* cream for dark copy laid over it,
 * rather than clamping brightness down.
 *
 * Drop it into any `position: relative`/`isolate` parent — it fills the
 * box. Built on `lib/shader-surface.ts`: no WebGL, a blocked or lost
 * context, a hidden tab, or `prefers-reduced-motion` all leave the CSS
 * `.iris-solarfanfield__floor` underneath visible, a still frame in the
 * same three stops.
 */

/* Palette, sRGB 0–1. Uniforms, not tokens — this cream-to-ember mood lives
   nowhere in the site's own accent ramp, same reasoning as `amber-glow`. */
const PALETTE: Record<string, [number, number, number]> = {
  u_cream: [0.992, 0.973, 0.937], // pale ivory ground, upper-right corner
  u_peach: [0.972, 0.78, 0.624], // wash between the rays at mid-radius
  u_coral: [0.91, 0.494, 0.31], // the ray bands themselves
  u_ember: [0.62, 0.204, 0.11], // the hot core where they converge
};

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_cream;
uniform vec3 u_peach;
uniform vec3 u_coral;
uniform vec3 u_ember;

uniform vec4  u_readA;
uniform float u_guard;

const float PI = 3.14159265;
const float RAYS = 17.0;

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);

  /* idle sway — a slow angular wobble, not a positional drift, so a still
     fan still reads as pinned to its corner rather than floating. */
  float t = u_time * 0.05;
  float idle = sin(t * 0.7) * 0.03;

  /* micro-interaction 1: the convergence point leans toward the cursor,
     eased (u_pointer.xy already carries the ease from shader-surface.ts). */
  vec2 ptr = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);
  vec2 pull = ptr * 0.05 * u_pointer.z;

  vec2 origin = vec2(-aspect * 0.34, -0.86) + pull;
  vec2 v = P - origin;
  float dist = length(v);
  float angle = atan(v.y, v.x) + idle;

  /* every ray edge gets its own bend instead of a shared straight line */
  angle += fbm(P * 1.5 + t * 0.15) * 0.055;

  /* micro-interaction 2: rays within reach of the cursor bend around it —
     a localized push keyed to screen-space distance from the pointer, not
     distance from the origin, so the warp travels with the cursor rather
     than rotating the whole fan. */
  float distToPtr = length(P - ptr);
  float bend = exp(-distToPtr * distToPtr * 6.5) * u_pointer.z;
  angle += bend * 0.4;

  /* radial falloff: hot near the corner, true cream by mid-frame — cut
     with an explicit smoothstep on top of the exponential tail so the far
     corner is genuinely flat, not just decayed. */
  float edgeCut = smoothstep(2.6, 0.0, dist);
  float radial  = exp(-dist * 0.62) * edgeCut;
  float core    = exp(-dist * 1.5) * edgeCut;

  float band = sin(angle * RAYS);
  float ray = smoothstep(-0.2, 0.2, band);

  vec3 col = u_cream;
  col = mix(col, u_peach, clamp(radial, 0.0, 1.0) * 0.72);
  col = mix(col, u_coral, clamp(ray * radial, 0.0, 1.0));
  col = mix(col, u_ember, clamp(ray * core, 0.0, 1.0) * 0.85);

  /* the cursor's own small warm halo, fading in with presence alone */
  float dCursor = dot(P - ptr, P - ptr);
  float halo = exp(-dCursor * 10.0) * u_pointer.z;
  col = mix(col, vec3(1.0, 0.97, 0.92), halo * 0.35);
  col += u_coral * halo * 0.2;

  col = clamp(col, 0.0, 1.0);

  /* ---- the reading guard ---- */
  /* Pale ground, dark copy — washes *toward* cream, the inverse of the
     catalogue's dark fields. */
  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, mix(col, u_cream, 0.7), guardBand * u_guard);

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

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

export interface SolarFanFieldProps {
  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 SolarFanField({ className, guardSelector = null }: SolarFanFieldProps) {
  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 AmberGlowField/OpalField: 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="iris-solarfanfield__floor absolute inset-0 [background:radial-gradient(_130%_130%_at_8%_112%,transparent_0%,oklch(0.975_0.018_85)_60%_),repeating-conic-gradient(_from_205deg_at_8%_112%,oklch(0.87_0.1_48)_0deg_10.5deg,oklch(0.82_0.15_38)_10.5deg_21deg_),oklch(0.975_0.018_85)]" />
      <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