docs / sula / sula-field

SulaField

an ambient veil of slow liquid glass: dark masses heave behind, lit glass drifts and kisses in front, and your content stays crisp and interactive above it.

intensity · assertsrsc · client

provenance

authored in usva

layer

sula

intensity

asserts · one per region. it is the focal point

composition

behind a landing hero, a splash, a section break. one per viewno children makes it a bare background layer you position yourselfnever under a second sula surface, and never behind a work surfacenever CSS-scale it. the pool spills out of the transformed box

a11y

the canvas layer is aria-hidden and pointer-transparent · content renders above it exactly as it would with no fieldjest-axe

dependencies

motion · ogl · sula-core from the same package
live demo · try it out

mist, made of glass

slow glass gathers at the edges and leaves the words clear

customize
speeddrift-rate multiplier
1
interactivethe veil leans toward the pointer
seedsame value wanders the same way
0
fluidoff mounts no canvas, one static frame
usagecopy
import { SulaField } from "usva/sula/sula-field";

<SulaField
>
  <Hero />
</SulaField>

props

proptypedefaultnotes
childrenReactNoderendered in normal flow above the field. omit it for a bare layer you position yourself.
speednumber1drift-rate multiplier. higher drifts faster.
driveSulaFieldDriveambientDriftthe choreography: a pure function of time and bounds. clamped to 12 bodies per plane and 8 necks, the surplus is not drawn.
interactivebooleanfalsethe field leans toward the eased cursor. off so the background does not compete.
seednumber0the same value wanders the same way, across renders and SSR.
fluidbooleantruefalse mounts no canvas. reduced motion paints one static frame instead.
stillTimenumber0which instant of the drive the reduced-motion still is taken from, in seconds.
accentColorstringaccent tokenrim light and glow on the blobs.
backdropstringbg tokenthe colour the glass tints against.
tintstringsurface-2 tokenthe glass itself.
shinenumberper theme0 is flat matte glass, 1 is the full neon rim.

get it

npx shadcn add https://usva.build/r/sula-field.jsoncopy
source · components/ui/field-geometry.tsexactly what this command copiescopy
import { type Blob, bridgeNecks, type Neck } from "./geometry";
import { clamp01, smoother } from "./curves";

export interface DriftOpts {
  width: number;
  height: number;
  /** Feeds every path phase, so a seed reproduces the same wander. */
  seed: number;
}

export interface FieldGeometry {
  /** Two huge, dark, shoulder-off-canvas masses: the atmosphere, no rim. */
  back: Blob[];
  /** Lit actors and glints that drift, kiss and part in front. */
  front: Blob[];
  /** Scheduled-kiss bridges among the front actors. */
  necks: Neck[];
}

const TAU = Math.PI * 2;

/** The full loop closes at 96 s: every band's cycle count is an integer, so the
 * whole field returns to its start seamlessly and deterministically. */
export const LOOP_T = 96;

/** Fraction of the short side used as the front merge radius. Higher than the
 * old single-pass k so a kiss reads as a fat waist, not a thread. */
export const FRONT_K_FRACTION = 0.11;
/** The back pass merges soupy and huge, so its two anchors read as one cloud. */
export const BACK_K_FRACTION = 0.2;

/** Eased approach of the pointer lean, per frame. */
export const POINTER_EASE = 0.08;
/** The heavy wake that drags a blob toward the cursor is slower still: a liquid
 * has mass, so it lags. */
export const WAKE_EASE = 0.03;

/**
 * A deterministic hash in [0, 1) from an integer stream and the seed. Only has to
 * look unpatterned and stay identical across renders so the drift is stable for
 * SSR and tests.
 */
function hash(n: number, seed: number): number {
  const x = Math.sin((n + 1) * 12.9898 + seed * 78.233) * 43758.5453;
  return x - Math.floor(x);
}

const clamp = (v: number, lo: number, hi: number): number =>
  Math.min(hi, Math.max(lo, v));

/** Three incommensurate sine bands with descending weight. Integer cycle counts
 * over LOOP_T keep it seamless; the coprime-ish triples keep the period from
 * ever visibly repeating inside two minutes. */
const BAND_WEIGHTS = [0.6, 0.3, 0.1];
function drift(
  time: number,
  amp: number,
  cycles: readonly [number, number, number],
  phase: number,
): number {
  let s = 0;
  for (let j = 0; j < 3; j++) {
    const f = ((cycles[j] as number) * TAU) / LOOP_T;
    s += (BAND_WEIGHTS[j] as number) * Math.sin(time * f + phase + j * 2.399);
  }
  return amp * s;
}

interface Spec {
  baseX: number;
  baseY: number;
  r: number;
  amp: number;
  cyclesX: readonly [number, number, number];
  cyclesY: readonly [number, number, number];
  breath: number;
  offCanvas: boolean;
}

function place(spec: Spec, time: number, opts: DriftOpts, id: number): Blob {
  const { width, height, seed } = opts;
  const px = hash(id * 16 + 6, seed) * TAU;
  const py = hash(id * 16 + 7, seed) * TAU;
  const breathe =
    1 +
    spec.breath *
      Math.sin((time * TAU) / LOOP_T + hash(id * 16 + 8, seed) * TAU);
  const r = spec.r * breathe;
  const dx = drift(time, spec.amp, spec.cyclesX, px);
  const dy = drift(time, spec.amp, spec.cyclesY, py);
  const slackX = spec.offCanvas ? 0.35 * r : r;
  const slackY = spec.offCanvas ? 0.35 * r : r;
  const cx = clamp(spec.baseX * width + dx, -slackX, width + slackX);
  const cy = clamp(spec.baseY * height + dy, -slackY, height + slackY);
  return { cx, cy, hw: r, hh: r, r };
}

/** One scheduled encounter: which two front actors meet, when in the loop, and
 * how wide the window is (as loop fractions). */
const KISSES: ReadonlyArray<{
  i: number;
  j: number;
  at: number;
  half: number;
}> = [
  { i: 0, j: 1, at: 0.22, half: 0.06 },
  { i: 1, j: 2, at: 0.61, half: 0.06 },
  { i: 0, j: 2, at: 0.87, half: 0.05 },
];

export function fieldFrame(time: number, opts: DriftOpts): FieldGeometry {
  const { width, height } = opts;
  const short = Math.min(width, height);

  const backSpecs: Spec[] = [
    {
      baseX: 0.16,
      baseY: 0.84,
      r: short * 0.45,
      amp: short * 0.04,
      cyclesX: [1, 3, 7],
      cyclesY: [2, 5, 11],
      breath: 0.06,
      offCanvas: true,
    },
    {
      baseX: 0.86,
      baseY: 0.2,
      r: short * 0.34,
      amp: short * 0.045,
      cyclesX: [3, 7, 13],
      cyclesY: [1, 5, 9],
      breath: 0.06,
      offCanvas: true,
    },
  ];

  const midSpecs: Spec[] = [
    { baseX: 0.3, baseY: 0.68, r: short * 0.16 },
    { baseX: 0.52, baseY: 0.5, r: short * 0.13 },
    { baseX: 0.68, baseY: 0.36, r: short * 0.11 },
  ].map((s) => ({
    ...s,
    amp: short * 0.12,
    cyclesX: [2, 5, 11],
    cyclesY: [3, 8, 13],
    breath: 0.05,
    offCanvas: false,
  }));

  const glintSpecs: Spec[] = [
    { baseX: 0.72, baseY: 0.72, r: short * 0.05 },
    { baseX: 0.34, baseY: 0.24, r: short * 0.035 },
  ].map((s) => ({
    ...s,
    amp: short * 0.18,
    cyclesX: [3, 8, 13],
    cyclesY: [5, 13, 21],
    breath: 0.04,
    offCanvas: false,
  }));

  const back = backSpecs.map((s, i) => place(s, time, opts, i));
  const mid = midSpecs.map((s, i) => place(s, time, opts, 10 + i));
  const glints = glintSpecs.map((s, i) => place(s, time, opts, 20 + i));

  const k = short * FRONT_K_FRACTION;
  const loopPhase = (((time % LOOP_T) + LOOP_T) % LOOP_T) / LOOP_T;
  const necks: Neck[] = [];
  for (const kiss of KISSES) {
    const w = 1 - clamp01(Math.abs(loopPhase - kiss.at) / kiss.half);
    if (w <= 0) continue;
    const ramp = smoother(w);
    const a = mid[kiss.i] as Blob;
    const b = mid[kiss.j] as Blob;
    const dx = b.cx - a.cx;
    const dy = b.cy - a.cy;
    const dist = Math.hypot(dx, dy) || 1;
    const ux = dx / dist;
    const uy = dy / dist;
    const midX = (a.cx + b.cx) / 2;
    const midY = (a.cy + b.cy) / 2;
    const pull = ramp * 0.92;
    a.cx += (midX - ux * (a.r + 0.15 * k) - a.cx) * pull;
    a.cy += (midY - uy * (a.r + 0.15 * k) - a.cy) * pull;
    b.cx += (midX + ux * (b.r + 0.15 * k) - b.cx) * pull;
    b.cy += (midY + uy * (b.r + 0.15 * k) - b.cy) * pull;
    const stretch = 1 + 0.1 * ramp;
    a.hw *= stretch;
    b.hw *= stretch;
    necks.push(...bridgeNecks([a, b], k, ramp));
  }

  return { back, front: [...mid, ...glints], necks };
}

/** Finds the surface under pressure, rather than making the whole veil follow
 * the pointer through its first (and largest) blob. */
export function nearestBlob(
  blobs: Blob[],
  point: { x: number; y: number },
): Blob | undefined {
  let nearest: Blob | undefined;
  let nearestDistance = Number.POSITIVE_INFINITY;
  for (const blob of blobs) {
    const distance =
      Math.hypot(blob.cx - point.x, blob.cy - point.y) -
      Math.max(blob.hw, blob.hh);
    if (distance < nearestDistance) {
      nearest = blob;
      nearestDistance = distance;
    }
  }
  return nearest;
}
source · components/ui/drive.tsexactly what this command copiescopy
import type { Blob, Neck } from "./geometry";
import {
  BACK_K_FRACTION,
  FRONT_K_FRACTION,
  fieldFrame,
} from "./field-geometry";

/**
 * How many bodies each depth plane may hold, and how many necks the whole frame
 * may hold. The shader's uniform arrays are fixed, so these are hard ceilings:
 * a drive that hands back more is clamped to the first N, and told so once in
 * development. Silently dropping the tail is how a tear ends up with no neck.
 */
export const MAX_FIELD_BLOBS = 12;
export const MAX_FIELD_NECKS = 8;

/** A rounded box of fluid, in CSS pixels from the field's top-left corner. */
export interface SulaBlob {
  cx: number;
  cy: number;
  /** Corner radius. A body with hw = hh = r is a circle. */
  r: number;
  /** Half-width. Defaults to `r`. */
  hw?: number;
  /** Half-height. Defaults to `r`. */
  hh?: number;
}

/**
 * A capsule joining two points: the tether of a tear, or the waist of a merge.
 * `strength` fades the bridge back into the surface without thinning it, which
 * is the only way a neck melts instead of snapping. Defaults to a solid bridge.
 */
export interface SulaNeck {
  ax: number;
  ay: number;
  bx: number;
  by: number;
  r: number;
  strength?: number;
}

/** The field's box, in CSS pixels, plus the seed the consumer was handed. */
export interface SulaFieldBounds {
  width: number;
  height: number;
  seed: number;
}

/**
 * What the fluid is doing at one instant. The consumer says what the material is
 * doing; the field decides how to paint it.
 */
export interface SulaDriveFrame {
  /** Matte bodies behind everything, drawn without a rim. Depth, not actors. */
  back?: SulaBlob[];
  /** The lit bodies. */
  front?: SulaBlob[];
  /** Bridges among the front bodies. */
  necks?: SulaNeck[];
  /** Merge radius of the front plane, in px. Defaults to 11% of the short side. */
  mergeRadius?: number;
  /** Merge radius of the back plane, in px. Defaults to 20% of the short side. */
  backMergeRadius?: number;
}

/**
 * A pure function of time. Given the seconds elapsed (already scaled by `speed`)
 * and the field's bounds, it returns the frame. Deterministic by contract: the
 * same time and bounds must give the same frame, so a drive can be unit-tested
 * and a still frame under reduced motion is just the frame at t = 0.
 */
export type SulaFieldDrive = (
  time: number,
  bounds: SulaFieldBounds,
) => SulaDriveFrame;

export interface ResolvedDriveFrame {
  back: Blob[];
  front: Blob[];
  necks: Neck[];
  kFront: number;
  kBack: number;
  /** True when the drive handed back more than a plane can hold. */
  clamped: boolean;
}

const toBlob = (b: SulaBlob): Blob => ({
  cx: b.cx,
  cy: b.cy,
  hw: b.hw ?? b.r,
  hh: b.hh ?? b.r,
  r: b.r,
});

/**
 * Turns whatever the drive said into exactly what the renderer can take: plane
 * budgets enforced, half-extents filled in, merge radii defaulted off the short
 * side so a drive never has to know the field's pixel size to look right.
 */
export function resolveDriveFrame(
  frame: SulaDriveFrame,
  bounds: SulaFieldBounds,
): ResolvedDriveFrame {
  const short = Math.min(bounds.width, bounds.height);
  const back = frame.back ?? [];
  const front = frame.front ?? [];
  const necks = frame.necks ?? [];
  return {
    back: back.slice(0, MAX_FIELD_BLOBS).map(toBlob),
    front: front.slice(0, MAX_FIELD_BLOBS).map(toBlob),
    necks: necks.slice(0, MAX_FIELD_NECKS).map((n) => ({ ...n })),
    kFront: frame.mergeRadius ?? short * FRONT_K_FRACTION,
    kBack: frame.backMergeRadius ?? short * BACK_K_FRACTION,
    clamped:
      back.length > MAX_FIELD_BLOBS ||
      front.length > MAX_FIELD_BLOBS ||
      necks.length > MAX_FIELD_NECKS,
  };
}

/** The built-in choreography: the ambient drift the field has always had. */
export const ambientDrift: SulaFieldDrive = (time, bounds) => {
  const { back, front, necks } = fieldFrame(time, bounds);
  return { back, front, necks };
};
source · components/ui/sula-field.tsxexactly what this command copiescopy
"use client";
import { useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
  createField,
  type FieldColors,
  liftTint,
  resolveColor,
  shineForBackdrop,
} from "./field";
import { packHover, packUniforms } from "./geometry";
import { createPauseGate } from "./pause";
import { useContextRecovery } from "./recovery";
import {
  ambientDrift,
  MAX_FIELD_BLOBS,
  MAX_FIELD_NECKS,
  resolveDriveFrame,
  type SulaFieldDrive,
} from "./drive";
import { nearestBlob, POINTER_EASE, WAKE_EASE } from "./field-geometry";

// Bundlers replace this expression statically, but a Vite app types itself with
// `types: ["vite/client"]` and has no `process`, so copied source fails tsc
// without a local declaration. Module scope, so it shadows nothing in Next.
declare const process: { env: { NODE_ENV?: string } };

export interface SulaFieldProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Drift-rate multiplier; higher drifts faster. Defaults to 1. */
  speed?: number;
  /**
   * The choreography: a pure function of time and the field's bounds, returning
   * the bodies and necks for that instant. Defaults to the ambient drift. Each
   * depth plane holds up to MAX_FIELD_BLOBS bodies and the frame up to
   * MAX_FIELD_NECKS necks; anything past that is clamped away, so keep inside it.
   */
  drive?: SulaFieldDrive;
  /** When on, blobs lean toward the eased cursor. Defaults to false. */
  interactive?: boolean;
  /** Reproduces the same wander for a given value. Defaults to 0. */
  seed?: number;
  /** false mounts no canvas; reduced-motion paints one static frame. */
  fluid?: boolean;
  /**
   * Which instant of the drive the reduced-motion still frame is taken from, in
   * seconds. Defaults to 0, which is right for a drive that is already composed
   * at rest, and wrong for one that has to run before there is anything to see:
   * a cycle that lifts bodies out of a pool is an empty pool at t=0. Pick the
   * moment that reads as the whole idea, held.
   */
  stillTime?: number;
  accentColor?: string;
  backdrop?: string;
  tint?: string;
  shine?: number;
  children?: React.ReactNode;
}

const MAX_DPR = 2;
/** Peak surface undulation, per depth pass. The back cloud heaves slowly; the
 * front actors shimmer tighter. */
const BACK_WOBBLE = 2.5;
const FRONT_WOBBLE = 1.2;
/** Peak edge displacement of the pointer lean, in px. */
const HOVER_WOBBLE = 1.4;
/** Peak position lean toward the cursor, as a fraction of the short side: the
 * heavy wake that trails the pointer. */
const WAKE_REACH = 0.06;
/** Gaussian falloff radius of the wake, as a fraction of the short side. Every
 * actor within it leans, weighted by proximity, so the pull glides across the
 * field instead of snapping from one nearest blob to the next. */
const WAKE_SPREAD = 0.45;

function readColors(
  node: HTMLElement,
  overrides: {
    backdrop?: string;
    tint?: string;
    accent?: string;
    shine?: number;
  },
): FieldColors {
  const styles = getComputedStyle(node);
  const token = (name: string) => styles.getPropertyValue(name).trim();
  const backdrop = resolveColor(overrides.backdrop ?? token("--usva-bg"));
  const tintToken =
    overrides.tint ?? token("--usva-surface-2") ?? token("--usva-surface");
  return {
    backdrop,
    tint: resolveColor(tintToken || token("--usva-surface")),
    accent: resolveColor(overrides.accent ?? token("--usva-accent")),
    shine: overrides.shine ?? shineForBackdrop(backdrop),
  };
}

export const SulaField = React.forwardRef<HTMLDivElement, SulaFieldProps>(
  (
    {
      speed = 1,
      drive,
      interactive = false,
      seed = 0,
      fluid = true,
      stillTime = 0,
      accentColor,
      backdrop,
      tint,
      shine,
      className,
      children,
      ...props
    },
    forwardedRef,
  ) => {
    const reduced = useReducedMotion();
    const containerRef = React.useRef<HTMLDivElement | null>(null);
    const canvasRef = React.useRef<HTMLCanvasElement | null>(null);

    /* These tune what the loop draws, not the GL field, so they ride refs rather
     * than effect deps: a live prop change must never tear the context down and
     * rebuild it on the same canvas, which races a scheduled frame. */
    const speedRef = React.useRef(speed);
    speedRef.current = speed;
    const seedRef = React.useRef(seed);
    seedRef.current = seed;
    const interactiveRef = React.useRef(interactive);
    interactiveRef.current = interactive;
    const driveRef = React.useRef<SulaFieldDrive>(drive ?? ambientDrift);
    driveRef.current = drive ?? ambientDrift;

    const { failed, generation, onContextLost, onContextReady } =
      useContextRecovery(canvasRef);
    const overrides = React.useMemo(
      () => ({ backdrop, tint, accent: accentColor, shine }),
      [backdrop, tint, accentColor, shine],
    );
    const overridesRef = React.useRef(overrides);
    overridesRef.current = overrides;
    /* This surface swaps two colour sets per pass, so a retune recomputes both
     * rather than pushing one through setColors, which the next pass would
     * overwrite anyway. */
    const refreshRef = React.useRef<(() => void) | null>(null);
    const [mounted, setMounted] = React.useState(false);
    React.useEffect(() => setMounted(true), []);

    const animated = fluid && !reduced && !failed && mounted;
    const still = fluid && reduced && !failed && mounted;
    const fieldOn = animated || still;

    // biome-ignore lint/correctness/useExhaustiveDependencies: `generation` is not read here, it is what rebuilds the field on a restored context
    React.useEffect(() => {
      if (!fieldOn) return;
      const canvas = canvasRef.current;
      const container = containerRef.current;
      if (!canvas || !container) return;

      let base = readColors(container, overridesRef.current);
      let backColors: FieldColors = { ...base, shine: 0 };
      let frontColors: FieldColors = {
        ...base,
        tint: liftTint(base.tint, base.accent),
      };
      const refreshColors = () => {
        base = readColors(container, overridesRef.current);
        backColors = { ...base, shine: 0 };
        frontColors = { ...base, tint: liftTint(base.tint, base.accent) };
      };
      refreshRef.current = refreshColors;

      const field = createField({
        canvas,
        colors: frontColors,
        onContextLost,
      });
      if (!field) {
        onContextLost();
        return;
      }
      onContextReady();

      let width = 0;
      let height = 0;
      let dpr = 1;
      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, MAX_DPR);
        if (w !== width || h !== height || nextDpr !== dpr) {
          canvas.style.width = `${w}px`;
          canvas.style.height = `${h}px`;
          field.resize(w, h, nextDpr);
        }
        width = w;
        height = h;
        dpr = nextDpr;
        return true;
      };

      let hoverAmt = 0;
      let hoverTarget = 0;
      let wakeAmt = 0;
      const pointer = { x: 0, y: 0 };

      /* Two passes into one context: a dark, soupy back cloud clears the frame,
       * then the lit actors composite over it (clear:false). Colors are set per
       * pass, so the back stays matte (shine 0) and the front carries the rim. */
      let warned = false;
      const draw = (elapsed: number) => {
        const bounds = { width, height, seed: seedRef.current };
        const resolved = resolveDriveFrame(
          driveRef.current(elapsed * speedRef.current, bounds),
          bounds,
        );
        const { back, front, necks, kFront, kBack } = resolved;
        if (resolved.clamped && !warned) {
          warned = true;
          if (process.env.NODE_ENV !== "production") {
            console.warn(
              `SulaField: the drive returned more than ${MAX_FIELD_BLOBS} bodies in a plane or ${MAX_FIELD_NECKS} necks. The surplus is not drawn.`,
            );
          }
        }
        const short = Math.min(width, height);
        const focus = nearestBlob(front, pointer);
        if (interactiveRef.current && wakeAmt > 0.001) {
          const reach = wakeAmt * WAKE_REACH * short;
          const spread = short * WAKE_SPREAD;
          for (const b of front) {
            const dx = pointer.x - b.cx;
            const dy = pointer.y - b.cy;
            const dist = Math.hypot(dx, dy) || 1;
            const fall = Math.exp(-((dist / spread) ** 2));
            b.cx += (dx / dist) * reach * fall;
            b.cy += (dy / dist) * reach * fall;
          }
        }

        field.setColors(backColors);
        field.draw({
          packed: packUniforms(
            { blobs: back, necks: [], k: kBack },
            dpr,
            height,
          ),
          k: kBack * dpr,
          time: elapsed,
          wobble: BACK_WOBBLE,
          alpha: 1,
          hover: null,
          clear: true,
        });

        field.setColors(frontColors);
        field.draw({
          packed: packUniforms({ blobs: front, necks, k: kFront }, dpr, height),
          k: kFront * dpr,
          time: elapsed,
          wobble: FRONT_WOBBLE,
          alpha: 1,
          hover:
            interactiveRef.current && focus && hoverAmt > 0.01
              ? packHover(focus, hoverAmt * HOVER_WOBBLE, dpr, height, pointer)
              : null,
          clear: false,
        });
      };

      const start = performance.now();
      if (!measure()) return;

      if (still) {
        draw(stillTime);
        const observer =
          typeof ResizeObserver === "undefined"
            ? null
            : new ResizeObserver(() => {
                if (measure()) draw(stillTime);
              });
        observer?.observe(container);
        return () => {
          observer?.disconnect();
          field.dispose();
          refreshRef.current = null;
          canvas.style.width = "";
          canvas.style.height = "";
        };
      }

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

      const onMove = (event: PointerEvent) => {
        if (!interactiveRef.current) return;
        const box = container.getBoundingClientRect();
        pointer.x = event.clientX - box.left;
        pointer.y = event.clientY - box.top;
        hoverTarget = 1;
      };
      const onLeave = () => {
        hoverTarget = 0;
      };
      container.addEventListener("pointermove", onMove);
      container.addEventListener("pointerleave", onLeave);

      const gate = createPauseGate({
        target: container,
        onPause: stop,
        onResume: run,
      });

      const observer =
        typeof ResizeObserver === "undefined"
          ? null
          : new ResizeObserver(() => measure());
      observer?.observe(container);

      const themeObserver =
        typeof MutationObserver === "undefined"
          ? null
          : new MutationObserver(() => refreshColors());
      themeObserver?.observe(document.documentElement, {
        attributes: true,
        attributeFilter: ["data-theme", "class"],
      });

      run();

      return () => {
        killed = true;
        container.removeEventListener("pointermove", onMove);
        container.removeEventListener("pointerleave", onLeave);
        gate.dispose();
        observer?.disconnect();
        themeObserver?.disconnect();
        stop();
        field.dispose();
        canvas.style.width = "";
        canvas.style.height = "";
      };
    }, [fieldOn, still, stillTime, generation]);

    // biome-ignore lint/correctness/useExhaustiveDependencies: `overrides` is not read here, the effect closure reads it live
    React.useEffect(() => {
      refreshRef.current?.();
    }, [overrides]);

    return (
      <div
        ref={(node) => {
          containerRef.current = node;
          if (typeof forwardedRef === "function") forwardedRef(node);
          else if (forwardedRef) forwardedRef.current = node;
        }}
        data-fluid={fieldOn ? "on" : "off"}
        className={cn("relative isolate overflow-hidden", className)}
        {...props}
      >
        {fieldOn ? (
          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-0 -z-10"
            style={{
              backgroundImage:
                "radial-gradient(120% 90% at 12% 88%, color-mix(in oklab, var(--usva-accent) 7%, transparent), transparent 55%)",
            }}
          >
            <canvas ref={canvasRef} className="block h-full w-full" />
          </div>
        ) : null}
        {children}
      </div>
    );
  },
);
SulaField.displayName = "SulaField";