shadimage
usva
uiatmospheres-coresource: ok · hit

Atmospheres Core

npx shadcn add "https://usva.build/r/atmospheres-core.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · heavywebgl
animationmotion
webglogl

Files6 · source cached 10/14/2026, 12:51:24 PM

atmospheres-color.ts
export type Rgb = [number, number, number];

/** Dark ground: emit. Light ground: stain the ground like pigment. */
export type BlendMode = "emissive" | "absorptive";

const BLACK: Rgb = [0, 0, 0];

/**
 * A canvas cannot sample the page behind it, so every colour reaches the shader
 * as channels. Tokens are authored in oklch, and painting the string into a 1px
 * canvas is the only reliable way to resolve an arbitrary CSS colour.
 */
export function resolveColor(value: string): Rgb {
  if (typeof document === "undefined") return BLACK;
  const text = value.trim();
  if (!text) return BLACK;
  try {
    const canvas = document.createElement("canvas");
    canvas.width = 1;
    canvas.height = 1;
    const ctx = canvas.getContext("2d", { willReadFrequently: true });
    if (!ctx) return BLACK;
    ctx.fillStyle = text;
    ctx.fillRect(0, 0, 1, 1);
    const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
    return [(r ?? 0) / 255, (g ?? 0) / 255, (b ?? 0) / 255];
  } catch {
    return BLACK;
  }
}

function toLinear(channel: number): number {
  const c = Math.min(Math.max(channel, 0), 1);
  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}

/** WCAG relative luminance, 0..1, from sRGB channels. */
export function relativeLuminance([r, g, b]: Rgb): number {
  return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}

/**
 * The house law. Emissive glow on a light ground cannot be made to look matte,
 * so a light ground flips the atmosphere to pigment. The threshold sits well clear
 * of both poles: savi beige lands near 0.72, every dark theme under 0.02.
 */
export const LIGHT_GROUND = 0.4;

export function blendModeFor(background: Rgb): BlendMode {
  return relativeLuminance(background) > LIGHT_GROUND
    ? "absorptive"
    : "emissive";
}

export function resolveBlendMode(
  override: BlendMode | undefined,
  background: Rgb,
): BlendMode {
  return override ?? blendModeFor(background);
}

/**
 * The compositing half of the flip. Emissive canvases add their light to the
 * page; absorptive ones multiply, so a translucent pixel stains the ground and
 * a transparent one leaves it untouched.
 */
export function blendStyleFor(mode: BlendMode): {
  mixBlendMode: "plus-lighter" | "multiply";
} {
  return {
    mixBlendMode: mode === "absorptive" ? "multiply" : "plus-lighter",
  };
}

/** 1.0 when the shader should absorb, 0.0 when it should emit. */
export function blendUniform(mode: BlendMode): number {
  return mode === "absorptive" ? 1 : 0;
}

function mixRgb(a: Rgb, b: Rgb, t: number): Rgb {
  return [
    a[0] + (b[0] - a[0]) * t,
    a[1] + (b[1] - a[1]) * t,
    a[2] + (b[2] - a[2]) * t,
  ];
}

export function pigmentFor(hue: Rgb, ink: Rgb): Rgb {
  return mixRgb(ink, hue, 0.22);
}

export const MAX_STAIN = 0.62;
atmospheres-ground.ts
import type { BlendMode } from "./atmospheres-color";

export type GroundSupport = "port" | "restrict" | "forbid";

export const LIGHT_GROUND_SUPPORT = {
  kynnos: "port",
  vare: "port",
  utu: "port",
  kuulto: "restrict",
  hehku: "forbid",
  loimu: "forbid",
  kajastus: "forbid",
  routa: "port",
} as const satisfies Record<string, GroundSupport>;

export type AtmosphereName = keyof typeof LIGHT_GROUND_SUPPORT;

export function supportsGround(
  name: AtmosphereName,
  mode: BlendMode,
): GroundSupport | "n/a" {
  return mode === "emissive" ? "n/a" : LIGHT_GROUND_SUPPORT[name];
}

/** True when the atmosphere must not paint: a light ground it has no form on. */
export function hiddenOnGround(name: AtmosphereName, mode: BlendMode): boolean {
  const support = supportsGround(name, mode);
  return support === "forbid" || support === "restrict";
}
atmospheres-glsl.ts
/**
 * Shared GLSL as strings, concatenated into a shader at build time. Every
 * fragment shader stays standalone: these are functions, never a framework.
 * An atmosphere must not redeclare a name it pulls in from here.
 */

const hash13 = /* glsl */ `
float hash11(float p) {
  p = fract(p * 0.1031);
  p *= p + 33.33;
  p *= p + p;
  return fract(p);
}

float hash12(vec2 p) {
  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}

float hash13(vec3 p) {
  p = fract(p * 0.1031);
  p += dot(p, p.yzx + 33.33);
  return fract((p.x + p.y) * p.z);
}

vec2 hash22(vec2 p) {
  vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.xx + p3.yz) * p3.zy);
}

vec3 hash33(vec3 p) {
  p = fract(p * vec3(0.1031, 0.1030, 0.0973));
  p += dot(p, p.yxz + 33.33);
  return fract((p.xxy + p.yxx) * p.zyx);
}
`;

const vnoise = /* glsl */ `
float vnoise2(vec2 x) {
  vec2 i = floor(x);
  vec2 f = fract(x);
  f = f * f * (3.0 - 2.0 * f);
  float a = hash12(i);
  float b = hash12(i + vec2(1.0, 0.0));
  float c = hash12(i + vec2(0.0, 1.0));
  float d = hash12(i + vec2(1.0, 1.0));
  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}

float vnoise(vec3 x) {
  vec3 i = floor(x);
  vec3 f = fract(x);
  f = f * f * (3.0 - 2.0 * f);
  float n000 = hash13(i + vec3(0.0, 0.0, 0.0));
  float n100 = hash13(i + vec3(1.0, 0.0, 0.0));
  float n010 = hash13(i + vec3(0.0, 1.0, 0.0));
  float n110 = hash13(i + vec3(1.0, 1.0, 0.0));
  float n001 = hash13(i + vec3(0.0, 0.0, 1.0));
  float n101 = hash13(i + vec3(1.0, 0.0, 1.0));
  float n011 = hash13(i + vec3(0.0, 1.0, 1.0));
  float n111 = hash13(i + vec3(1.0, 1.0, 1.0));
  return mix(
    mix(mix(n000, n100, f.x), mix(n010, n110, f.x), f.y),
    mix(mix(n001, n101, f.x), mix(n011, n111, f.x), f.y),
    f.z
  );
}
`;

const fbm = /* glsl */ `
float fbm(vec3 p, int octaves) {
  float a = 0.5;
  float s = 0.0;
  for (int i = 0; i < octaves; i++) {
    s += a * vnoise(p);
    p = p * 2.02 + vec3(11.3, 7.7, 3.1);
    a *= 0.5;
  }
  return s;
}

float fbm2(vec2 p, int octaves) {
  float a = 0.5;
  float s = 0.0;
  for (int i = 0; i < octaves; i++) {
    s += a * vnoise2(p);
    p = p * 2.02 + vec2(11.3, 7.7);
    a *= 0.5;
  }
  return s;
}
`;

const curl = /* glsl */ `
vec3 curlPotential(vec3 p) {
  return vec3(
    fbm(p, 3),
    fbm(p + vec3(31.4, 17.7, 9.2), 3),
    fbm(p + vec3(-7.1, 23.9, 41.3), 3)
  );
}

/** Divergence-free: the field shears and stretches but never bunches. */
vec3 curl(vec3 p, float eps) {
  vec3 dx = vec3(eps, 0.0, 0.0);
  vec3 dy = vec3(0.0, eps, 0.0);
  vec3 dz = vec3(0.0, 0.0, eps);
  vec3 px1 = curlPotential(p + dx);
  vec3 px0 = curlPotential(p - dx);
  vec3 py1 = curlPotential(p + dy);
  vec3 py0 = curlPotential(p - dy);
  vec3 pz1 = curlPotential(p + dz);
  vec3 pz0 = curlPotential(p - dz);
  float k = 1.0 / (2.0 * eps);
  return vec3(
    (py1.z - py0.z) - (pz1.y - pz0.y),
    (pz1.x - pz0.x) - (px1.z - px0.z),
    (px1.y - px0.y) - (py1.x - py0.x)
  ) * k;
}

/** The 2D case is the perpendicular gradient of a scalar potential. */
vec2 curl2(vec2 p, float eps) {
  float ny1 = fbm2(p + vec2(0.0, eps), 3);
  float ny0 = fbm2(p - vec2(0.0, eps), 3);
  float nx1 = fbm2(p + vec2(eps, 0.0), 3);
  float nx0 = fbm2(p - vec2(eps, 0.0), 3);
  float k = 1.0 / (2.0 * eps);
  return vec2(ny1 - ny0, nx0 - nx1) * k;
}
`;

const worley = /* glsl */ `
vec2 worleyF(vec2 p) {
  vec2 ip = floor(p);
  vec2 fp = fract(p);
  float f1 = 8.0;
  float f2 = 8.0;
  for (int y = -1; y <= 1; y++) {
    for (int x = -1; x <= 1; x++) {
      vec2 g = vec2(float(x), float(y));
      vec2 o = hash22(ip + g);
      vec2 r = g + o - fp;
      float d = dot(r, r);
      if (d < f1) {
        f2 = f1;
        f1 = d;
      } else if (d < f2) {
        f2 = d;
      }
    }
  }
  return vec2(sqrt(f1), sqrt(f2));
}

float worley(vec2 p) {
  return worleyF(p).x;
}

/** F2 - F1: zero on the cell walls, so it draws cracks, not cells. */
float craquelure(vec2 p) {
  vec2 f = worleyF(p);
  return f.y - f.x;
}
`;

const isoband = /* glsl */ `
float isoband(float fb, float aa) {
  return smoothstep(0.5 - aa, 0.5, fb) * (1.0 - smoothstep(0.5, 0.5 + aa, fb));
}

/** Anti-aliased contour rims of a field, count bands per unit. */
float isobands(float v, float count) {
  float b = v * count;
  float aa = fwidth(b) * 1.5 + 0.04;
  return isoband(fract(b), aa);
}
`;

const dither = /* glsl */ `
/** Interleaved gradient noise: blue-noise-ish for the cost of one fract. */
float ign(vec2 fragCoord) {
  return fract(52.9829189 * fract(dot(fragCoord, vec2(0.06711056, 0.00583715))));
}

vec3 dither(vec3 col, vec2 fragCoord, float amount) {
  return col + (ign(fragCoord) - 0.5) * amount;
}

float ditherAlpha(float a, vec2 fragCoord, float amount) {
  return a + (ign(fragCoord) - 0.5) * amount;
}
`;

const tonemap = /* glsl */ `
vec3 tonemapExp(vec3 c, float exposure) {
  return vec3(1.0) - exp(-c * exposure);
}

vec3 tonemapACES(vec3 x) {
  const float a = 2.51;
  const float b = 0.03;
  const float c = 2.43;
  const float d = 0.59;
  const float e = 0.14;
  return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}
`;

const stain = /* glsl */ `
float soak(float depth, float sigma) {
  return clamp(1.0 - exp(-max(depth, 0.0) * sigma), 0.0, 1.0);
}

vec3 hold(vec3 pigment, float floorLevel) {
  float lum = dot(pigment, vec3(0.2126, 0.7152, 0.0722));
  return clamp(pigment * (max(lum, floorLevel) / max(lum, 1e-4)), 0.0, 1.0);
}
`;

const composite = /* glsl */ `
/**
 * Safari ignores premultipliedAlpha: false and composites the drawing buffer as
 * though it were premultiplied, so an unpremultiplied colour is effectively
 * divided by its own alpha and blows out. The context is therefore premultiplied
 * and every atmosphere ends here.
 *
 * The alpha is squared and the colour attenuated by it because the old pipeline
 * did exactly that: SRC_ALPHA blending into a cleared buffer wrote rgb * a with
 * alpha a * a, and the browser then premultiplied again on composite. Every
 * atmosphere was tuned against that image, so it is the image, not a mistake to
 * unwind.
 */
vec4 composite(vec3 rgb, float alpha) {
  float a = clamp(alpha, 0.0, 1.0);
  float cov = a * a;
  return vec4(clamp(rgb, 0.0, 1.0) * a * cov, cov);
}
`;

interface Chunk {
  needs: readonly GlslChunk[];
  source: string;
}

export type GlslChunk =
  | "hash13"
  | "vnoise"
  | "fbm"
  | "curl"
  | "worley"
  | "isoband"
  | "dither"
  | "tonemap"
  | "stain"
  | "composite";

const CHUNKS: Record<GlslChunk, Chunk> = {
  hash13: { needs: [], source: hash13 },
  vnoise: { needs: ["hash13"], source: vnoise },
  fbm: { needs: ["vnoise"], source: fbm },
  curl: { needs: ["fbm"], source: curl },
  worley: { needs: ["hash13"], source: worley },
  isoband: { needs: [], source: isoband },
  dither: { needs: [], source: dither },
  tonemap: { needs: [], source: tonemap },
  stain: { needs: [], source: stain },
  composite: { needs: [], source: composite },
};

export const GLSL_CHUNK_NAMES = Object.keys(CHUNKS) as GlslChunk[];

/** Concatenates chunks with their dependencies, each emitted exactly once. */
export function glsl(...names: readonly GlslChunk[]): string {
  const seen = new Set<GlslChunk>();
  const parts: string[] = [];
  const visit = (name: GlslChunk) => {
    if (seen.has(name)) return;
    seen.add(name);
    for (const need of CHUNKS[name].needs) visit(need);
    parts.push(CHUNKS[name].source);
  };
  for (const name of names) visit(name);
  return parts.join("\n");
}
atmospheres-gl.ts
import { Mesh, Program, Renderer, Triangle } from "ogl";

/**
 * ogl gates array uniforms on Array.isArray, so vectors must be plain number
 * arrays. A Float32Array silently uploads nothing at all.
 */
export type UniformValue = number | number[] | boolean;
export type Uniforms = Record<string, { value: UniformValue }>;

/** Writes a uniform if the shader declares it, so a trimmed shader never throws. */
export function setUniform(
  uniforms: Uniforms,
  name: string,
  value: UniformValue,
): void {
  const slot = uniforms[name];
  if (slot) slot.value = value;
}

export const fullscreenVertexShader = /* glsl */ `#version 300 es
in vec2 position;
void main() {
  gl_Position = vec4(position, 0.0, 1.0);
}
`;

export interface GlSurface {
  uniforms: Uniforms;
  resize(width: number, height: number, dpr: number): void;
  render(): void;
  dispose(): void;
}

export interface CreateGlSurfaceOptions {
  canvas: HTMLCanvasElement;
  fragment: string;
  vertex?: string;
  uniforms: Uniforms;
  onContextLost?: () => void;
}

/**
 * The shared shell: a WebGL2 context drawing one fullscreen triangle. It knows
 * nothing about any image. Returns null when WebGL2 is unavailable, so callers
 * fall back rather than throw.
 */
export function createGlSurface(
  options: CreateGlSurfaceOptions,
): GlSurface | null {
  const { canvas, fragment, uniforms, onContextLost } = options;

  let renderer: Renderer;
  try {
    renderer = new Renderer({
      canvas,
      webgl: 2,
      dpr: 1,
      alpha: true,
      // Safari composites the buffer as premultiplied whatever this says, so the
      // only value both browsers agree on is true. Shaders end in composite().
      premultipliedAlpha: true,
      antialias: false,
    });
  } catch {
    return null;
  }

  const gl = renderer.gl;
  if (!gl || !("drawBuffers" in gl)) return null;
  gl.clearColor(0, 0, 0, 0);

  let program: Program;
  try {
    program = new Program(gl, {
      vertex: options.vertex ?? fullscreenVertexShader,
      fragment,
      transparent: true,
      depthTest: false,
      uniforms,
    });
  } catch {
    return null;
  }

  const mesh = new Mesh(gl, { geometry: new Triangle(gl), program });
  const live = program.uniforms as Uniforms;

  const handleLost = (event: Event) => {
    event.preventDefault();
    onContextLost?.();
  };
  canvas.addEventListener("webglcontextlost", handleLost);

  return {
    uniforms: live,
    resize(width, height, dpr) {
      renderer.dpr = dpr;
      renderer.setSize(width, height);
      const resolution = live.uResolution;
      if (resolution) resolution.value = [width * dpr, height * dpr];
    },
    render() {
      renderer.render({ scene: mesh });
    },
    dispose() {
      canvas.removeEventListener("webglcontextlost", handleLost);
      program.remove();
      mesh.geometry.remove();
    },
  };
}
use-token-colors.ts
"use client";
import * as React from "react";
import {
  type BlendMode,
  blendModeFor,
  type Rgb,
  relativeLuminance,
  resolveColor,
} from "./atmospheres-color";

/**
 * Increments on every theme change. For atmospheres whose colours come from props
 * rather than roles: re-resolve on this and repaint.
 */
export function useThemeVersion(): number {
  const [version, setVersion] = React.useState(0);
  React.useEffect(() => {
    if (typeof MutationObserver === "undefined") return;
    const observer = new MutationObserver(() => setVersion((v) => v + 1));
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["data-theme", "class", "style"],
    });
    return () => observer.disconnect();
  }, []);
  return version;
}

export interface TokenColors<R extends string> {
  /** Resolved channels for each requested role, keyed by role name. */
  colors: Record<R, Rgb>;
  /** The page ground, always resolved, because the blend flip depends on it. */
  bg: Rgb;
  /** Relative luminance of the ground, 0..1. */
  luminance: number;
  /** The house law, defaulted from the ground. */
  mode: BlendMode;
}

export interface UseTokenColorsOptions {
  /** Resolve against a scoped theme node rather than the document root. */
  scopeRef?: React.RefObject<HTMLElement | null>;
}

const BLACK: Rgb = [0, 0, 0];

function read<R extends string>(
  roles: readonly R[],
  scope: HTMLElement | null,
): TokenColors<R> {
  const node = scope ?? document.documentElement;
  const styles = getComputedStyle(node);
  const token = (role: string) =>
    styles.getPropertyValue(`--usva-${role}`).trim();

  const colors = {} as Record<R, Rgb>;
  for (const role of roles) colors[role] = resolveColor(token(role));

  const bg = resolveColor(token("bg"));
  return {
    colors,
    bg,
    luminance: relativeLuminance(bg),
    mode: blendModeFor(bg),
  };
}

function fallback<R extends string>(roles: readonly R[]): TokenColors<R> {
  const colors = {} as Record<R, Rgb>;
  for (const role of roles) colors[role] = BLACK;
  return { colors, bg: BLACK, luminance: 0, mode: "emissive" };
}

function serialize<R extends string>(state: TokenColors<R>): string {
  return `${state.mode}|${state.bg.join()}|${Object.entries(state.colors)
    .map(([role, rgb]) => `${role}:${(rgb as Rgb).join()}`)
    .join("|")}`;
}

/**
 * Resolves token roles to channels and re-resolves them whenever the theme
 * changes. Without the MutationObserver a runtime theme swap leaves every
 * canvas painted in the colours of the theme it mounted under.
 */
export function useTokenColors<R extends string>(
  roles: readonly R[],
  options: UseTokenColorsOptions = {},
): TokenColors<R> {
  const { scopeRef } = options;
  const key = roles.join(",");
  const rolesRef = React.useRef(roles);
  rolesRef.current = roles;

  const [state, setState] = React.useState<TokenColors<R>>(() =>
    fallback(roles),
  );

  // biome-ignore lint/correctness/useExhaustiveDependencies: the role list rides a ref, so its contents key the effect, not its identity.
  React.useEffect(() => {
    if (typeof document === "undefined") return;
    const current = rolesRef.current;

    let last = "";
    const sync = () => {
      const next = read(current, scopeRef?.current ?? null);
      const signature = serialize(next);
      if (signature === last) return;
      last = signature;
      setState(next);
    };
    sync();

    if (typeof MutationObserver === "undefined") return;
    const observer = new MutationObserver(sync);
    const attributeFilter = ["data-theme", "class", "style"];
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter,
    });
    const scope = scopeRef?.current;
    if (scope && scope !== document.documentElement) {
      observer.observe(scope, { attributes: true, attributeFilter });
    }
    return () => observer.disconnect();
  }, [key, scopeRef]);

  return state;
}
use-gl-canvas.ts
"use client";
import { useReducedMotion } from "motion/react";
import * as React from "react";
import { createGlSurface, type Uniforms } from "./atmospheres-gl";

/** How long the window has to hold still before the drawing buffer is rebuilt. */
const RESIZE_SETTLE_MS = 150;

export interface CaptureOptions {
  /** Opaque backdrop painted under the atmosphere, eg the studio canvas colour. */
  bg: string;
  /** Multiplier on the backing-store size. Defaults to 1 (already dpr-scaled). */
  scale?: number;
}

type CaptureFn = (opts: CaptureOptions) => Promise<Blob>;

// The renderer runs with preserveDrawingBuffer:false, so the buffer is only
// readable synchronously in the same tick as render(). Each live canvas
// registers a grabber here; the drawImage read happens in-frame, the toBlob
// after is free.
const captureRegistry = new WeakMap<HTMLCanvasElement, CaptureFn>();

/**
 * Take an opaque PNG of a mounted atmosphere. Rejects when the canvas is not a
 * currently live atmosphere surface.
 */
export function captureAtmosphere(
  canvas: HTMLCanvasElement,
  opts: CaptureOptions,
): Promise<Blob> {
  const grab = captureRegistry.get(canvas);
  if (!grab)
    return Promise.reject(new Error("canvas is not a live atmosphere"));
  return grab(opts);
}

export interface GlPointer {
  /** Pixels from the container centre, y up. */
  x: number;
  y: number;
  /** Eases 0..1 as the pointer enters and leaves. */
  amount: number;
  inside: boolean;
}

export interface GlFrame {
  /** Seconds of visible animation time. Stops accruing while paused. */
  time: number;
  /** CSS pixels. */
  width: number;
  height: number;
  /** Device pixel ratio actually used for the backing store. */
  dpr: number;
  pointer: GlPointer;
}

export interface UseGlCanvasOptions {
  fragment: string;
  vertex?: string;
  /** Built once per context. Every uniform the shader reads must appear here. */
  uniforms: () => Uniforms;
  /** Called before each render. uResolution and uTime are already written. */
  onFrame?: (uniforms: Uniforms, frame: GlFrame) => void;
  /** Ceiling on the device pixel ratio. Defaults to 2. */
  maxDpr?: number;
  /** Backing-store scale. 0.5 renders at half res and lets the browser upscale. */
  renderScale?: number;
  /** Track the pointer over the container. Defaults to false. */
  pointer?: boolean;
  /** Per-frame ease of pointer.amount toward its target. */
  pointerEase?: number;
  /** Time fed to the still frame under prefers-reduced-motion. */
  stillTime?: number;
  /** Mount the context at all. Defaults to true. */
  enabled?: boolean;
}

export interface GlCanvas {
  containerRef: React.RefObject<HTMLDivElement | null>;
  canvasRef: React.RefObject<HTMLCanvasElement | null>;
  /** True when a canvas should be mounted: animating or holding a still frame. */
  active: boolean;
  /** True while the loop is running, false for the reduced-motion still frame. */
  animated: boolean;
  failed: boolean;
  /** Repaint once. Needed when a still frame's inputs change, eg on theme swap. */
  redraw: () => void;
}

const DEFAULT_POINTER_EASE = 0.06;

function approach(current: number, target: number, ease: number): number {
  return current + (target - current) * ease;
}

/** Composite the GL buffer over an opaque bg. drawImage must run in-frame. */
function grabToBlob(
  src: HTMLCanvasElement,
  { bg, scale = 1 }: CaptureOptions,
): Promise<Blob> {
  const w = Math.max(1, Math.round(src.width * scale));
  const h = Math.max(1, Math.round(src.height * scale));
  const out = document.createElement("canvas");
  out.width = w;
  out.height = h;
  const ctx = out.getContext("2d");
  if (!ctx) return Promise.reject(new Error("2d context unavailable"));
  ctx.fillStyle = bg;
  ctx.fillRect(0, 0, w, h);
  ctx.drawImage(src, 0, 0, w, h);
  return new Promise((resolve, reject) => {
    out.toBlob(
      (blob) => (blob ? resolve(blob) : reject(new Error("toBlob failed"))),
      "image/png",
    );
  });
}

/**
 * The plumbing every atmosphere repeats: context, sizing, pause, reduced motion,
 * context loss and cleanup ordering. It owns no image and no parameters.
 */
export function useGlCanvas(options: UseGlCanvasOptions): GlCanvas {
  const {
    fragment,
    vertex,
    maxDpr = 2,
    renderScale = 1,
    pointer: trackPointer = false,
    pointerEase = DEFAULT_POINTER_EASE,
    stillTime = 0,
    enabled = true,
  } = options;

  const reduced = useReducedMotion();
  const containerRef = React.useRef<HTMLDivElement | null>(null);
  const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
  const redrawRef = React.useRef<() => void>(() => {});

  const buildRef = React.useRef(options.uniforms);
  buildRef.current = options.uniforms;
  const frameRef = React.useRef(options.onFrame);
  frameRef.current = options.onFrame;

  const [failed, setFailed] = React.useState(false);
  const [mounted, setMounted] = React.useState(false);
  React.useEffect(() => setMounted(true), []);

  const live = mounted && enabled && !failed;
  const animated = live && !reduced;
  const still = live && !!reduced;
  const active = animated || still;

  React.useEffect(() => {
    if (!active) return;
    const canvas = canvasRef.current;
    const container = containerRef.current;
    if (!canvas || !container) return;

    const surface = createGlSurface({
      canvas,
      fragment,
      vertex,
      uniforms: buildRef.current(),
      onContextLost: () => setFailed(true),
    });
    if (!surface) {
      setFailed(true);
      return;
    }

    const scale = Math.max(renderScale, 0.1);
    let width = 0;
    let height = 0;
    let dpr = 0;

    const stretch = () => {
      const box = container.getBoundingClientRect();
      const w = Math.ceil(box.width);
      const h = Math.ceil(box.height);
      if (w <= 0 || h <= 0) return false;
      canvas.style.width = `${w}px`;
      canvas.style.height = `${h}px`;
      return true;
    };

    const measure = () => {
      const box = container.getBoundingClientRect();
      const w = Math.ceil(box.width);
      const h = Math.ceil(box.height);
      if (w <= 0 || h <= 0) return false;
      const nextDpr = Math.min(window.devicePixelRatio || 1, maxDpr) * scale;
      if (w !== width || h !== height || nextDpr !== dpr) {
        canvas.style.width = `${w}px`;
        canvas.style.height = `${h}px`;
        surface.resize(w, h, nextDpr);
      }
      width = w;
      height = h;
      dpr = nextDpr;
      return true;
    };

    let settleTimer = 0;
    const settle = (after: () => void) => {
      stretch();
      window.clearTimeout(settleTimer);
      settleTimer = window.setTimeout(() => {
        if (measure()) after();
      }, RESIZE_SETTLE_MS);
    };

    const pointer: GlPointer = { x: 0, y: 0, amount: 0, inside: false };
    let pointerTarget = 0;
    let elapsed = 0;
    let pending: {
      opts: CaptureOptions;
      resolve: (blob: Blob) => void;
      reject: (err: unknown) => void;
    } | null = null;

    const draw = (time: number) => {
      pointer.amount = approach(pointer.amount, pointerTarget, pointerEase);
      const u = surface.uniforms;
      const clock = u.uTime;
      if (clock) clock.value = time;
      frameRef.current?.(u, { time, width, height, dpr, pointer });
      surface.render();
      if (pending) {
        const { opts, resolve, reject } = pending;
        pending = null;
        grabToBlob(canvas, opts).then(resolve, reject);
      }
    };
    redrawRef.current = () => {
      if (measure()) draw(still ? stillTime : elapsed);
    };

    if (!measure()) {
      surface.dispose();
      return;
    }

    captureRegistry.set(
      canvas,
      (opts) =>
        new Promise<Blob>((resolve, reject) => {
          pending = { opts, resolve, reject };
          // Force one in-frame draw+grab; covers the paused and still cases too.
          redrawRef.current();
        }),
    );

    if (still) {
      draw(stillTime);
      const observer =
        typeof ResizeObserver === "undefined"
          ? null
          : new ResizeObserver(() => {
              settle(() => draw(stillTime));
            });
      observer?.observe(container);
      return () => {
        redrawRef.current = () => {};
        captureRegistry.delete(canvas);
        window.clearTimeout(settleTimer);
        observer?.disconnect();
        surface.dispose();
        canvas.style.width = "";
        canvas.style.height = "";
      };
    }

    let raf = 0;
    let last = performance.now();
    let killed = false;
    const tick = () => {
      if (killed) return;
      const now = performance.now();
      elapsed += (now - last) / 1000;
      last = now;
      draw(elapsed);
      raf = requestAnimationFrame(tick);
    };
    const stop = () => cancelAnimationFrame(raf);
    const run = () => {
      last = performance.now();
      stop();
      raf = requestAnimationFrame(tick);
    };

    let box = container.getBoundingClientRect();
    const remeasure = () => {
      box = container.getBoundingClientRect();
    };

    const onMove = (event: PointerEvent) => {
      if (!trackPointer) return;
      pointer.x = event.clientX - box.left - box.width / 2;
      pointer.y = box.height / 2 - (event.clientY - box.top);
      pointer.inside = true;
      pointerTarget = 1;
    };
    const onLeave = () => {
      pointer.inside = false;
      pointerTarget = 0;
    };
    if (trackPointer) {
      container.addEventListener("pointermove", onMove, { passive: true });
      container.addEventListener("pointerleave", onLeave, { passive: true });
      window.addEventListener("scroll", remeasure, {
        passive: true,
        capture: true,
      });
    }

    let visible = true;
    const io =
      typeof IntersectionObserver === "undefined"
        ? null
        : new IntersectionObserver((entries) => {
            visible = entries[0]?.isIntersecting ?? true;
            if (visible && !document.hidden) run();
            else stop();
          });
    io?.observe(container);

    const onVisibility = () => {
      if (document.hidden || !visible) stop();
      else run();
    };
    document.addEventListener("visibilitychange", onVisibility);

    const observer =
      typeof ResizeObserver === "undefined"
        ? null
        : new ResizeObserver(() => {
            settle(remeasure);
          });
    observer?.observe(container);

    run();

    return () => {
      killed = true;
      redrawRef.current = () => {};
      captureRegistry.delete(canvas);
      window.clearTimeout(settleTimer);
      if (trackPointer) {
        container.removeEventListener("pointermove", onMove);
        container.removeEventListener("pointerleave", onLeave);
        window.removeEventListener("scroll", remeasure, { capture: true });
      }
      io?.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
      observer?.disconnect();
      stop();
      surface.dispose();
      canvas.style.width = "";
      canvas.style.height = "";
    };
  }, [
    active,
    still,
    fragment,
    vertex,
    maxDpr,
    renderScale,
    trackPointer,
    pointerEase,
    stillTime,
  ]);

  const redraw = React.useCallback(() => redrawRef.current(), []);
  return { containerRef, canvasRef, active, animated, failed, redraw };
}

More from usva

Browse all components in this library →