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 usvalayer
sulaintensity
asserts · one per region. it is the focal pointcomposition
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 pagea11y
role="status" with aria-busy, announces its label · the field is aria-hidden · reduced motion gets the stilljest-axedependencies
motion · ogl · sula-core from the same packagelive demo · try it out
Loading
recoil, release, return
customize
motionwhich looping event it stages
sizesquare side in px
speedloop-rate multiplier
labelthe announced status
fluidoff renders the static still
shine0 matte glass, 1 full neon rim
usage
import { SulaLoader } from "usva/sula/sula-loader";
<SulaLoader />the still · fluid={false}, reduced motion, or no WebGL2
Loading
Loading
Loading
props
| prop | type | default | notes |
|---|---|---|---|
| size | number | 96 | square 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. |
| speed | number | 1 | loop-rate multiplier. higher is faster. |
| label | string | "Loading" | the status text a screen reader announces. |
| fluid | boolean | true | false renders a static still and mounts no canvas. |
| accentColor | string | — | rim light and glow on the droplets. defaults to the accent token. |
| backdrop | string | — | the colour the glass tints against. defaults to the bg token. |
| tint | string | — | the glass itself. defaults to the surface-2 token. |
| shine | number | — | 0 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.jsonsource · components/ui/loader-geometry.tsexactly what this command copies
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 copies
"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";