docs / sula / sula-loader

SulaLoader

a loading moment made of liquid glass. each loop stages one physical event: a bead tears free and returns, drops gather and scatter, or two masses trade momentum through a bridge. the loader unmounts the moment the work lands.

intensity · assertsrsc · client

provenance

authored in usva

layer

sula

intensity

asserts · one per region. it is the focal point

composition

the big wait: a route change, a splash, an empty panel, a hero settling inone per screen. it is the focal point while nothing else existsnot the spinner for a button, a table row, or a validating field. that is Spinnernever left looping behind a finished page

a11y

role="status" with aria-busy, announces its label · the field is aria-hidden · reduced motion gets the stilljest-axe

dependencies

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

recoil, release, return

customize
motionwhich looping event it stages
sizesquare side in px
96
speedloop-rate multiplier
1
labelthe announced status
fluidoff renders the static still
shine0 matte glass, 1 full neon rim
0.7
usagecopy
import { SulaLoader } from "usva/sula/sula-loader";

<SulaLoader />
the still · fluid={false}, reduced motion, or no WebGL2
Loading
Loading
Loading

props

proptypedefaultnotes
sizenumber96square side in px. the blobs scale off it, so this is the only sizing knob.
motion"orbit" | "cluster" | "twin""orbit"orbit relays a released bead; cluster gathers three unequal drops; twin exchanges momentum between two masses.
speednumber1loop-rate multiplier. higher is faster.
labelstring"Loading"the status text a screen reader announces.
fluidbooleantruefalse renders a static still and mounts no canvas.
accentColorstringrim light and glow on the droplets. defaults to the accent token.
backdropstringthe colour the glass tints against. defaults to the bg token.
tintstringthe glass itself. defaults to the surface-2 token.
shinenumber0 is flat matte glass, 1 is the full neon rim. derived from the backdrop when unset.

get it

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

export type LoaderMotion = "orbit" | "cluster" | "twin";

export interface LoaderFrame {
  blobs: Blob[];
  necks: Neck[];
  /** The merge radius this beat wants. It rides the frame, not a constant, so a
   * bead can thin the field to tear free and thicken it again to fuse. */
  k: number;
}

export interface LoaderOpts {
  /** The loader's square side in canvas px. Every radius derives from it. */
  size: number;
}

const TAU = Math.PI * 2;

/** Wraps any real number into [0, 1) so a raw accumulated phase is safe to index. */
const wrap = (t: number): number => t - Math.floor(t);

/** A symmetric 0 to 1 to 0 pulse over [a, b], peaking at the midpoint. */
const pulse = (a: number, b: number, p: number): number =>
  Math.sin(clamp01((p - a) / (b - a)) * Math.PI);

function circle(cx: number, cy: number, r: number): Blob {
  return { cx, cy, hw: r, hh: r, r };
}

/**
 * The sling: a core winds up, extrudes a droplet, snaps it free, whips it around
 * a long arc, then reaches out to swallow it with a plop. The field thins to a
 * hair while the bead flies so it reads as a separate drop, and thickens back to
 * fuse it on return. Mass is conserved: the core runs lean while the bead is
 * free and inflates past rest as it absorbs it.
 */
export function orbitFrame(phase: number, { size }: LoaderOpts): LoaderFrame {
  const c = size / 2;
  const coreR = size * 0.17;
  const beadR = size * 0.11;
  const p = wrap(phase);

  const launch = -Math.PI * 0.35;
  const ux = Math.cos(launch);
  const uy = Math.sin(launch);

  const emerge = smoothstep(0.1, 0.22, p);
  const collapse = smoothstep(0.76, 0.92, p);
  const gone = smoothstep(0.92, 1.0, p);

  const f = clamp01((p - 0.22) / (0.76 - 0.22));
  const swept = f + (0.5 * Math.sin(f * TAU)) / TAU;
  const beadAngle = launch - TAU * 0.92 * swept;
  const distOut = size * (0.35 + 0.02 * Math.sin(f * Math.PI));
  const dist = mix(coreR * 0.5, distOut, emerge) * (1 - collapse);
  const bx = c + Math.cos(beadAngle) * dist;
  const by = c + Math.sin(beadAngle) * dist;
  const beadRadius = beadR * emerge * (1 - gone);

  const beadPresent = emerge * (1 - gone);
  const swallow = pulse(0.86, 1.0, p);
  const recoil = smoothstep(0.1, 0.16, p) - smoothstep(0.16, 0.3, p);
  const antic = pulse(0.0, 0.12, p);
  /* Reach toward the returning bead, then let go once it is swallowed. The
   * release must finish before the loop seam, or the stretched width snaps back
   * to rest in a single frame at the wrap. */
  const reach = smoothstep(0.78, 0.88, p) * (1 - smoothstep(0.9, 0.98, p));
  const lean = smoothstep(0.3, 0.6, p) * (1 - smoothstep(0.76, 0.88, p));

  const coreScale = 1 - 0.12 * beadPresent + 0.14 * swallow;
  const bearingX = Math.abs(Math.cos(beadAngle));
  const bearingY = Math.abs(Math.sin(beadAngle));
  const core: Blob = {
    cx:
      c - ux * recoil * size * 0.05 + Math.cos(beadAngle) * lean * size * 0.015,
    cy:
      c - uy * recoil * size * 0.05 + Math.sin(beadAngle) * lean * size * 0.015,
    hw:
      coreR *
      coreScale *
      (1 + 0.12 * reach * bearingX + 0.06 * antic * Math.abs(uy)),
    hh:
      coreR *
      coreScale *
      (1 + 0.12 * reach * bearingY + 0.06 * antic * Math.abs(ux)),
    r: coreR * coreScale * (1 - 0.06 * antic),
  };
  const bead = circle(bx, by, beadRadius);

  /* Fat while the bead is being born or swallowed so those fuse smoothly, thin
   * through the free flight so the drop actually clears the core's merge field. */
  const flight = smoothstep(0.26, 0.32, p) * (1 - smoothstep(0.72, 0.78, p));
  const k = mix(size * 0.16, size * 0.05, flight);

  const dx = bx - core.cx;
  const dy = by - core.cy;
  const len = Math.hypot(dx, dy) || 1;
  const nx = dx / len;
  const ny = dy / len;

  /* Only tether across a real gap. While the bead is still buried in the core
   * (being born, or fully swallowed) the smooth-min carries the fusion and a
   * neck would project a capsule out past the merged surface, popping it. */
  const gap = len - coreR - beadRadius;
  const gapGate = smoothstep(-beadRadius, 0, gap);
  const releaseStr = 1 - smoothstep(0.16, 0.22, p);
  const captureStr = smoothstep(0.78, 0.86, p) * (1 - gone);
  const strength = Math.max(releaseStr, captureStr) * gapGate;
  if (beadRadius <= 0.5 || strength <= 0.01) {
    return { blobs: [core, bead], necks: [], k };
  }
  const neck: Neck = {
    ax: core.cx + nx * coreR,
    ay: core.cy + ny * coreR,
    bx: bx - nx * beadRadius,
    by: by - ny * beadRadius,
    r: beadRadius * 0.72,
    strength,
  };
  return { blobs: [core, bead], necks: [neck], k };
}

/** Radii of the three bloom lobes, largest first, so the mass is asymmetric. */
const BLOOM_RADII = [0.15, 0.125, 0.105];
const BLOOM_ANGLES = [-Math.PI * 0.56, Math.PI * 0.14, Math.PI * 0.81];

/**
 * The bloom: one mass breathes apart into a three-lobed clover and gathers back
 * into one, mitosis in reverse. The whole gesture rides a single cosine bump,
 * which is zero in both value and velocity at the loop seam, so the clover opens
 * and closes once per loop with no dwell and no jump where it wraps. The lobes
 * divide from the centre so their waists never leave bridge reach.
 */
export function clusterFrame(phase: number, { size }: LoaderOpts): LoaderFrame {
  const c = size / 2;
  const p = wrap(phase);
  const k = size * 0.12;

  const open = smoother(0.5 - 0.5 * Math.cos(p * TAU));
  const spin = Math.sin(p * TAU) * 0.12;

  const distances = [0.22, 0.24, 0.23];
  const blobs = BLOOM_RADII.map((rf, i) => {
    const angle = (BLOOM_ANGLES[i] as number) + spin + open * 0.12 * (i - 1);
    const d = size * (distances[i] as number) * open;
    const rr = size * (rf as number);
    return {
      cx: c + Math.cos(angle) * d,
      cy: c + Math.sin(angle) * d,
      hw: rr * (1 - 0.05 * open),
      hh: rr * (1 + 0.06 * open),
      r: rr * (1 - 0.03 * open),
    } satisfies Blob;
  });

  /* Fade the waists out as the clover gathers: once the lobes are nearly
   * coincident the smooth-min already fuses them, and a neck there would project
   * an oversized capsule out of the merged circle, snapping its silhouette for a
   * frame. Necks only exist while the lobes are genuinely apart. */
  const merge = smoothstep(0.1, 0.5, open);
  const [l0, l1, l2] = blobs as [Blob, Blob, Blob];
  const necks =
    merge <= 0.001
      ? []
      : [
          ...bridgeNecks([l0, l1], k, merge),
          ...bridgeNecks([l1, l2], k, merge),
          ...bridgeNecks([l2, l0], k, merge),
        ];
  return { blobs, necks, k };
}

/**
 * The binary: two unequal masses fall around a shared centre like a binary star.
 * An elliptical separation sweeps the whole expressive range of the neck, from a
 * fused peanut at perigee to a taut glowing thread at apogee, where it snaps for
 * one breath before they slam back together. Kepler timing (fast when close,
 * slow when far) is what reads as gravity instead of a mechanical spin.
 */
export function twinFrame(phase: number, { size }: LoaderOpts): LoaderFrame {
  const c = size / 2;
  const p = wrap(phase);
  const k = size * 0.16;
  const bigR = size * 0.17;
  const smallR = size * 0.12;

  const theta = p * TAU + 0.18 * Math.sin(p * TAU - 0.15 * TAU) - Math.PI * 0.2;
  const sep = size * (0.42 - 0.13 * Math.cos((p - 0.15) * TAU));
  const dirx = Math.cos(theta);
  const diry = Math.sin(theta);

  const bigMass = bigR * bigR;
  const smallMass = smallR * smallR;
  const total = bigMass + smallMass;
  const bigOff = (sep * smallMass) / total;
  const smallOff = (sep * bigMass) / total;

  const snap = 1 - smoothstep(0.55, 0.62, p);
  const reform = smoothstep(0.72, 0.8, p);
  const strength = p < 0.5 ? 1 : Math.max(snap, reform);
  const closeness = clamp01(1 - (sep - size * 0.29) / (size * 0.26));
  const stretch = strength * (1 - closeness);
  const recoil = pulse(0.55, 0.7, p) * 0.02;

  const big: Blob = {
    cx: c - dirx * (bigOff + recoil * size),
    cy: c - diry * (bigOff + recoil * size),
    hw: bigR * (1 + 0.14 * stretch * Math.abs(dirx)),
    hh: bigR * (1 + 0.14 * stretch * Math.abs(diry)),
    r: bigR * (1 - 0.05 * stretch),
  };
  const small: Blob = {
    cx: c + dirx * (smallOff + recoil * size),
    cy: c + diry * (smallOff + recoil * size),
    hw: smallR * (1 + 0.14 * stretch * Math.abs(dirx)),
    hh: smallR * (1 + 0.14 * stretch * Math.abs(diry)),
    r: smallR * (1 - 0.05 * stretch),
  };
  return {
    blobs: [big, small],
    necks: bridgeNecks([big, small], k, strength),
    k,
  };
}

export const LOADER_FRAMES: Record<
  LoaderMotion,
  (phase: number, opts: LoaderOpts) => LoaderFrame
> = {
  orbit: orbitFrame,
  cluster: clusterFrame,
  twin: twinFrame,
};

/** Representative stills for reduced motion and the no-WebGL fallback: each
 * caught at its most legible beat (bead out, clover open, thread taut). */
export const STATIC_PHASES: Record<LoaderMotion, number> = {
  orbit: 0.46,
  cluster: 0.5,
  twin: 0.48,
};

/** Loop period in seconds at speed 1, per motion. One legible beat per loop
 * wants slightly different pacing: the clover breathes slowest. */
export const LOOP_PERIODS: Record<LoaderMotion, number> = {
  orbit: 2.6,
  cluster: 3.0,
  twin: 2.4,
};

/** Default period, kept for callers that do not vary pacing by motion. */
export const LOOP_PERIOD = LOOP_PERIODS.orbit;

export function loaderFrame(
  motion: LoaderMotion,
  phase: number,
  size: number,
): LoaderFrame {
  return LOADER_FRAMES[motion](phase, { size });
}
source · components/ui/sula-loader.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,
  resolveColor,
  shineForBackdrop,
} from "./field";
import { packUniforms } from "./geometry";
import { useContextRecovery } from "./recovery";
import { useFieldRetune } from "./retune";
import {
  LOOP_PERIODS,
  type LoaderMotion,
  loaderFrame,
  STATIC_PHASES,
} from "./loader-geometry";

export interface SulaLoaderProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Square side in px. Defaults to 96. */
  size?: number;
  /** Which looping motion the droplets run. Defaults to "orbit". */
  motion?: LoaderMotion;
  /** Loop-rate multiplier; higher is faster. Defaults to 1. */
  speed?: number;
  /** The announced status text. Defaults to "Loading". */
  label?: string;
  /** false or reduced-motion renders a static still with no canvas. */
  fluid?: boolean;
  accentColor?: string;
  backdrop?: string;
  tint?: string;
  shine?: number;
}

const DEFAULT_SIZE = 96;
const MAX_DPR = 2;
/** Peak surface undulation in px. A loader is always in motion, so this is a
 * constant living shimmer rather than an energy-gated one. */
const WOBBLE = 1.2;
/** The slowest the loop may run, so a speed of 0 does not divide by zero. */
const MIN_SPEED = 0.05;

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 SulaLoader = React.forwardRef<HTMLDivElement, SulaLoaderProps>(
  (
    {
      size = DEFAULT_SIZE,
      motion = "orbit",
      speed = 1,
      label = "Loading",
      fluid = true,
      accentColor,
      backdrop,
      tint,
      shine,
      className,
      ...props
    },
    forwardedRef,
  ) => {
    const reduced = useReducedMotion();
    const rootRef = React.useRef<HTMLDivElement | null>(null);
    const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
    const gooId = React.useId();

    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;
    const fieldRef = React.useRef<ReturnType<typeof createField>>(null);
    const [mounted, setMounted] = React.useState(false);
    React.useEffect(() => setMounted(true), []);

    /* motion and speed drive what the loop draws, not the field itself, so they
     * ride refs instead of effect deps. Switching motion must not tear down the
     * GL context and rebuild it on the same canvas, which races the scheduled
     * frame against a disposed program. */
    const motionRef = React.useRef(motion);
    motionRef.current = motion;
    const speedRef = React.useRef(speed);
    speedRef.current = speed;

    const isFluid = fluid && !reduced && !failed && mounted;

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

      const field = createField({
        canvas,
        colors: readColors(root, overridesRef.current),
        onContextLost,
      });
      if (!field) {
        onContextLost();
        return;
      }
      fieldRef.current = field;
      onContextReady();

      const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
      canvas.style.width = `${size}px`;
      canvas.style.height = `${size}px`;
      field.resize(size, size, dpr);

      const start = performance.now();
      let phase = 0;
      let activeMotion = motionRef.current;
      let last = start;
      let raf = 0;
      let killed = false;

      const tick = () => {
        if (killed) return;
        const now = performance.now();
        const nextMotion = motionRef.current;
        if (nextMotion !== activeMotion) {
          activeMotion = nextMotion;
          phase = STATIC_PHASES[nextMotion];
        } else {
          const period =
            LOOP_PERIODS[activeMotion] / Math.max(MIN_SPEED, speedRef.current);
          phase = (phase + (now - last) / 1000 / period) % 1;
        }
        last = now;
        const { blobs, necks, k } = loaderFrame(activeMotion, phase, size);
        field.draw({
          packed: packUniforms({ blobs, necks, k }, dpr, size),
          k: k * dpr,
          time: (now - start) / 1000,
          wobble: WOBBLE,
          alpha: 1,
          hover: null,
        });
        raf = requestAnimationFrame(tick);
      };

      const stop = () => cancelAnimationFrame(raf);
      /* Resume from a fresh timestamp so a spell paused offscreen does not jump
       * the phase forward by the elapsed real time. */
      const run = () => {
        last = performance.now();
        stop();
        raf = requestAnimationFrame(tick);
      };
      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(root);

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

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

      run();

      return () => {
        killed = true;
        io?.disconnect();
        document.removeEventListener("visibilitychange", onVisibility);
        themeObserver?.disconnect();
        stop();
        field.dispose();
        fieldRef.current = null;
        canvas.style.width = "";
        canvas.style.height = "";
      };
    }, [isFluid, size, generation]);

    useFieldRetune(
      fieldRef,
      () => (rootRef.current ? readColors(rootRef.current, overrides) : null),
      overrides,
    );

    const still = loaderFrame(motion, STATIC_PHASES[motion], size);
    const blur = size * 0.05;

    return (
      <div
        ref={(node) => {
          rootRef.current = node;
          if (typeof forwardedRef === "function") forwardedRef(node);
          else if (forwardedRef) forwardedRef.current = node;
        }}
        role="status"
        aria-live="polite"
        aria-busy="true"
        data-fluid={isFluid ? "on" : "off"}
        style={{ width: size, height: size }}
        className={cn(
          "relative inline-grid place-items-center text-accent",
          className,
        )}
        {...props}
      >
        {isFluid ? (
          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-0"
          >
            <canvas ref={canvasRef} className="block h-full w-full" />
          </div>
        ) : (
          <svg
            aria-hidden="true"
            width={size}
            height={size}
            viewBox={`0 0 ${size} ${size}`}
            className="absolute inset-0"
          >
            <title>{label}</title>
            <defs>
              <filter id={gooId}>
                <feGaussianBlur in="SourceGraphic" stdDeviation={blur} />
                <feColorMatrix values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 18 -7" />
              </filter>
            </defs>
            <g fill="currentColor" filter={`url(#${gooId})`} opacity={0.85}>
              {still.blobs.map((b, i) => (
                <circle
                  // biome-ignore lint/suspicious/noArrayIndexKey: a fixed still frame, blobs never reorder
                  key={i}
                  cx={b.cx}
                  cy={b.cy}
                  r={b.r}
                />
              ))}
            </g>
          </svg>
        )}
        <span className="sr-only">{label}</span>
      </div>
    );
  },
);
SulaLoader.displayName = "SulaLoader";