Skip to content

Bell Breath Loader

A progress loader drawn as one translucent jellyfish. Progress is light: it rises through the bell from the rim, lights the ring of organs and lets the tentacles grow. With no value it breathes on a five second cycle, and at 100 it exhales, lifts 24 px and fades.

FreeAttentionminimal
Preview
Aerium / CommissionsFig. 06 / Loader
Sending commission, 64 percent
Unit: light, from the rim
Commission 0417 / Medium: air

Sending your commission

One dry aquarium, fourteen koi, a single shaft of light 1.2 m wide.

  • 01BriefDone
  • 02ReferencesDone
  • 03Light planWorking
  • 04ConfirmationQueued

Props

64
Indeterminate (5 s breath)
Replay completion (toggle)
152
Show the value
Show sample card
No runtime dependencies
"use client";

import { useEffect, useId, useRef, useSyncExternalStore } from "react";
import type { CSSProperties } from "react";

/**
 * BellBreathLoader: a progress indicator drawn as one jellyfish. The bell is
 * near-clear glass, almost transparent at the apex and denser toward a flared,
 * scalloped margin, with a ring of four dim horseshoe gonads inside. The
 * colour lives in two places only: the organs and the progress light. The light
 * rises through the bell from the rim, the organs glow when it reaches them,
 * and the tentacles grow as the number grows. With no value it breathes on a
 * 5 second cycle: one quick contraction, a slow release, the lappets flexing
 * and the tentacles trailing the bell with a real delay. At 100 it does one
 * exhale, lifts 24 px and fades over 600 ms, and is gone.
 *
 * HOW IT IS MADE. Inline SVG plus one small requestAnimationFrame loop, no
 * dependencies and no React render per frame:
 *   - the bell is a tall dome that flares into a margin of 12 lappets, rebuilt
 *     every frame so the lappets flex with the breath. Layers, bottom to top:
 *     a glass body gradient (clear above, denser at the margin), a dark cavity,
 *     the light (a soft-edged gradient slid up from the rim), the gonads, three
 *     faint layered edge strokes that fade toward the apex (a fresnel edge, not
 *     an outline), the lit margin and two glints;
 *   - 12 marginal tentacles and 4 oral arms are rebuilt every frame. A
 *     tentacle is a filled shape that tapers to a point (it fades by length),
 *     riding a travelling wave: the bell's contraction reaches a point a moment
 *     after it reaches the margin (the follow-through), on top of a lateral
 *     sway whose period differs per tentacle. The oral arms are soft frilled
 *     curtains of two overlapping layers;
 *   - progress is chased with a 0.35 s time constant, so a jump in the value
 *     is a ramp, never a flash. Nothing changes faster than the bell's own
 *     contraction (about 0.2 s) and the area involved is small, far under any
 *     flash threshold;
 *   - below 72 px wide the canals, the inner dome and the bead dots are left
 *     out on purpose (a simplified level of detail), so nothing goes sub-pixel;
 *   - the loop sleeps when the loader is off screen, the tab is hidden, or the
 *     exhale has finished.
 *
 * It is a real progressbar: role, aria-valuemin/max/now (the value is omitted
 * while indeterminate), an aria-label from the label prop and a visually
 * hidden text. Under prefers-reduced-motion it is one still frame (the bell
 * relaxed, lit to the current value) with a numeric readout, and the exhale is
 * replaced by the finished frame.
 *
 * Use it for an upload, a route change, an image, a long form submit. It is
 * never a page-gating splash. Hues: Medusa (pink core, violet bell, ice rim),
 * Ice, Ember, Abyss. Ink is #eaf1f6; set style.color for a light ground. The
 * exhale lifts the figure 24 px, so do not clip it with overflow: hidden.
 */

export type BellBreathLoaderHue = "Medusa" | "Ice" | "Ember" | "Abyss";

export type BellBreathLoaderProps = {
  /** 0 to 100. Leave it out (or pass null) for the indeterminate breath. */
  progress?: number | null;
  /** The accessible name, and the readout while indeterminate. */
  label?: string;
  /** Width in px, 32 to 320. The figure is 1.65 times as tall. */
  size?: number;
  /** The specimen colour. */
  hue?: BellBreathLoaderHue;
  /** Show the percentage (or the label when indeterminate) under the bell. */
  showValue?: boolean;
  /** Called once when progress reaches 100 (when the exhale begins). */
  onComplete?: () => void;
  className?: string;
  style?: CSSProperties;
};

function clamp(v: number, lo: number, hi: number, fallback: number) {
  return Number.isFinite(v) ? Math.min(hi, Math.max(lo, v)) : fallback;
}

const PALETTES: Record<BellBreathLoaderHue, { core: string; bell: string; rim: string }> = {
  Medusa: { core: "#ff5aa5", bell: "#6c4dff", rim: "#7fd8ff" },
  Ice: { core: "#d9ecf8", bell: "#4e7fc4", rim: "#a9dcff" },
  Ember: { core: "#ff7f1e", bell: "#b8452c", rim: "#ffd3a1" },
  Abyss: { core: "#2b63ff", bell: "#2a2fa0", rim: "#8fb0ff" },
};
/** The glass is near-neutral in every hue. */
const GLASS = "#cfe3f2";

const PERIOD = 5;
const TAU = Math.PI * 2;
const LMAX = 108;

function subscribeReduced(cb: () => void) {
  const m = window.matchMedia("(prefers-reduced-motion: reduce)");
  m.addEventListener("change", cb);
  return () => m.removeEventListener("change", cb);
}
function getReduced() {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function getReducedServer() {
  return false;
}

/* ---- geometry (viewBox 0 0 100 165, bell centred on x = 50) ---------------- */

/** The dome, left rim tip to right rim tip: widest about 65% of the way down, then curling inward to a narrower rim. */
const DOME_LINE = "M21 50C15 47 12 42 12 35C12 18 28 5 50 5C72 5 88 18 88 35C88 42 85 47 79 50";
const INNER = "M22 47C16 38 18 11 50 11C82 11 84 38 78 47";
const RIM_L = 21;
const RIM_R = 79;
const RIM_Y = 50;
const SAG = 3.2;
/** The rim is a slight ellipse seen 3/4 on: the front edge sags below the tips. */
const rimAt = (x: number) => RIM_Y + SAG * Math.sin((Math.PI * (RIM_R - x)) / (RIM_R - RIM_L));
/** Fourteen small lappets along the front edge, right to left. c is the contraction 0..1: they curl a little deeper. */
function scallops(c: number) {
  let s = "";
  const w = (RIM_R - RIM_L) / 14;
  for (let k = 0; k < 14; k++) {
    const x1 = RIM_R - w * k;
    const xm = x1 - w / 2;
    const x2 = x1 - w;
    const depth = 1 + c * (0.9 + 0.5 * Math.sin(k * 2.1));
    s += "Q" + xm.toFixed(2) + " " + (rimAt(xm) + depth).toFixed(2) + " " + x2.toFixed(2) + " " + rimAt(x2).toFixed(2);
  }
  return s;
}
const MOUTH = "M21 50Q50 45.4 79 50Q50 56.2 21 50Z";
const CANALS = [
  "M50 12C34 14 22 24 20.5 38",
  "M50 12C42 18 35 32 31 49",
  "M50 12V51",
  "M50 12C58 18 65 32 69 49",
  "M50 12C66 14 78 24 79.5 38",
];
const GLINT_A = "M20.5 36C20 23 29 12 43 9";
const GLINT_B = "M63 9C70 10.5 77 16 79.5 24";
/* Four soft kidney-shaped gonads in a cross, seen through the glass and blurred: a glow, not a shape. */
const KIDNEY = "M-4.6 0C-4.6 -2.7 -1.5 -3.3 0 -2.2C1.5 -3.3 4.6 -2.7 4.6 0C4.6 2.7 1.8 3.1 0 1.5C-1.8 3.1 -4.6 2.7 -4.6 0Z";
const GONADS = [
  { x: 50, y: 29.5, r: 180, s: 0.8 },
  { x: 50, y: 38.5, r: 0, s: 1 },
  { x: 41, y: 34, r: -80, s: 0.9 },
  { x: 59, y: 34, r: 80, s: 0.9 },
];

/* Marginal tentacles hang from the lappet junctions (x = 50 + rel). A few run
   much longer than the rest. */
const THIN = [-27.5, -22.5, -17.5, -12.5, -7.5, -3, 3, 7.5, 12.5, 17.5, 22.5, 27.5].map((rel, i) => ({
  rel,
  len: [60, 84, 70, 106, 64, 96, 102, 66, 108, 76, 88, 58][i] ?? 70,
  per: 4.2 + ((i * 7) % 5) * 0.62,
  ph: i * 1.9,
  amp: 4 + ((i * 3) % 4) * 0.8,
  freq: 1.35 + ((i * 5) % 4) * 0.16,
  drift: (((i * 5) % 7) - 3) * 1.5,
  slow: ((i * 37) % 10) / 10,
  w: 0.6 + ((i * 5) % 4) * 0.1,
}));

const ORAL = [
  { rel: -5, y: 33, len: 82, per: 5.3, ph: 0.4, amp: 4.4 },
  { rel: -1.8, y: 34, len: 100, per: 6.1, ph: 2.1, amp: 5 },
  { rel: 1.8, y: 34, len: 92, per: 4.7, ph: 3.7, amp: 4.8 },
  { rel: 5, y: 33, len: 72, per: 5.9, ph: 5, amp: 4.2 },
];

/* ---- motion maths ---------------------------------------------------------- */

const clamp01 = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x);
function smooth(a: number, b: number, x: number) {
  const t = clamp01((x - a) / (b - a));
  return t * t * (3 - 2 * t);
}
/** One breath: a quick contraction (peak at 31% of the cycle), a slow release. */
function pulse(u: number) {
  const s = Math.sin(Math.PI * Math.pow(clamp01(u), 0.62));
  return s * s;
}
function cyc(tau: number) {
  const u = (tau / PERIOD) % 1;
  return u < 0 ? u + 1 : u;
}

/** A closed shape around a centre line, half-width hw(s), left side down and right side back up. */
function ribbon(xs: number[], ys: number[], hw: (s: number) => number) {
  const n = xs.length - 1;
  let left = "";
  let right = "";
  for (let k = 0; k <= n; k++) {
    const ax = (xs[Math.min(k + 1, n)] ?? 0) - (xs[Math.max(k - 1, 0)] ?? 0);
    const ay = (ys[Math.min(k + 1, n)] ?? 0) - (ys[Math.max(k - 1, 0)] ?? 0);
    const len = Math.hypot(ax, ay) || 1;
    const h = hw(k / n);
    const x = xs[k] ?? 0;
    const y = ys[k] ?? 0;
    const nx = (-ay / len) * h;
    const ny = (ax / len) * h;
    left += (k === 0 ? "M" : "L") + (x + nx).toFixed(2) + " " + (y + ny).toFixed(2);
    right = "L" + (x - nx).toFixed(2) + " " + (y - ny).toFixed(2) + right;
  }
  return left + right + "Z";
}

type Tent = { halo: SVGPathElement | null; body: SVGPathElement | null; line: SVGPathElement | null };
type Els = {
  svg: SVGSVGElement;
  fig: SVGGElement;
  bell: SVGGElement;
  light: SVGGElement;
  glow: SVGEllipseElement;
  organ: SVGGElement;
  rim: SVGPathElement;
  aura: SVGEllipseElement;
  thinG: SVGGElement;
  oralG: SVGGElement;
  shape: SVGPathElement[];
  thin: Tent[];
  oral: SVGPathElement[][];
};

/** Writes one frame: p is the smoothed light level 0..1, cFn(a) the contraction
 *  that has reached distance a (0..1) down a tentacle, calm scales the sway,
 *  wk widens the hairlines at small sizes. */
function draw(E: Els, tau: number, p: number, cFn: (a: number) => number, calm: number, wk: number) {
  const c0 = cFn(0);
  const sx = 1 - 0.075 * c0;
  const sy = 1 + 0.05 * c0;
  E.bell.setAttribute("transform", "translate(50 5) scale(" + sx.toFixed(4) + " " + sy.toFixed(4) + ") translate(-50 -5)");
  const sc = scallops(c0);
  const shape = DOME_LINE + sc + "Z";
  for (const el of E.shape) el.setAttribute("d", shape);
  E.rim.setAttribute("d", "M79 50" + sc);
  const bob = -2.4 * c0 + 0.8 * Math.sin((TAU * tau) / 9.3) * calm;
  const tilt = 1.1 * Math.sin((TAU * tau) / 11.1 + 1) * calm;
  const dx = 0.5 * Math.sin((TAU * tau) / 13.7) * calm;
  E.fig.setAttribute("transform", "translate(" + dx.toFixed(2) + " " + bob.toFixed(2) + ") rotate(" + tilt.toFixed(2) + " 50 30)");

  const front = 60 - 86 * p;
  E.light.setAttribute("transform", "translate(0 " + front.toFixed(2) + ")");
  E.glow.setAttribute("opacity", (smooth(0.15, 0.8, p) * (0.92 + 0.08 * c0)).toFixed(3));
  E.organ.setAttribute("opacity", (0.3 + 0.7 * clamp01((41 - front) / 20)).toFixed(3));
  E.rim.setAttribute("opacity", (0.45 + 0.55 * clamp01(p * 5)).toFixed(3));
  E.aura.setAttribute("opacity", (0.3 + 0.7 * p).toFixed(3));
  E.thinG.setAttribute("opacity", (0.7 + 0.3 * smooth(0, 0.7, p)).toFixed(3));
  E.oralG.setAttribute("opacity", (0.6 + 0.4 * smooth(0.05, 0.8, p)).toFixed(3));
  const tintO = (0.25 + 0.75 * smooth(0.1, 0.9, p)).toFixed(3);

  const N = 20;

  THIN.forEach((g, i) => {
    const T = E.thin[i];
    if (!T) return;
    const t = 0.14 + 0.86 * clamp01(p / (0.7 + 0.3 * g.slow));
    const Lt = g.len * t;
    const rimY = 5 + (rimAt(50 + g.rel) - 5) * sy;
    const xs: number[] = [];
    const ys: number[] = [];
    for (let k = 0; k <= N; k++) {
      const s = k / N;
      const a = (s * Lt) / LMAX;
      const cl = cFn(a);
      const rel = g.rel * (1 - 0.075 * cl) * (1 + 0.28 * Math.sin(Math.PI * Math.min(1, a * 1.3)) - 0.25 * a);
      const wave = g.amp * calm * a * (0.4 + 0.6 * a) * Math.sin(TAU * (tau / g.per - g.freq * a) + g.ph) + g.drift * Math.sin(Math.PI * a * 0.85);
      xs.push(50 + rel + wave);
      ys.push(rimY + s * Lt * (1 - 0.1 * cl));
    }
    const w0 = (g.w * wk) / 2;
    const taper = (s: number) => w0 * Math.pow(1 - s, 1.25) + 0.03;
    if (T.body) T.body.setAttribute("d", ribbon(xs, ys, taper));
    if (T.halo) T.halo.setAttribute("d", ribbon(xs, ys, (s) => taper(s) * 3.2));
    if (T.line) {
      let d = "M" + (xs[0] ?? 0).toFixed(2) + " " + (ys[0] ?? 0).toFixed(2);
      for (let k = 1; k < N; k++) {
        const x0 = xs[k] ?? 0;
        const y0 = ys[k] ?? 0;
        d += "Q" + x0.toFixed(2) + " " + y0.toFixed(2) + " " + ((x0 + (xs[k + 1] ?? 0)) / 2).toFixed(2) + " " + ((y0 + (ys[k + 1] ?? 0)) / 2).toFixed(2);
      }
      T.line.setAttribute("d", d + "L" + (xs[N] ?? 0).toFixed(2) + " " + (ys[N] ?? 0).toFixed(2));
    }
  });

  const M = 26;
  ORAL.forEach((g, j) => {
    const pair = E.oral[j];
    if (!pair) return;
    const t = 0.3 + 0.7 * clamp01(p / (0.8 + 0.05 * j));
    const Lt = g.len * t;
    const yRoot = 5 + (g.y - 5) * sy;
    const xs: number[] = [];
    const ys: number[] = [];
    const fa: number[] = [];
    for (let k = 0; k <= M; k++) {
      const s = k / M;
      const a = (s * Lt) / LMAX;
      const cl = cFn(a * 0.9 + 0.1);
      xs.push(50 + g.rel * (1 - 0.075 * cl) * (1 - 0.25 * a) + g.amp * calm * t * s * (0.35 + 0.65 * s) * Math.sin(TAU * (tau / g.per - 1.15 * s) + g.ph));
      ys.push(yRoot + s * Lt * (1 - 0.08 * cl));
      fa.push(1 + 0.22 * Math.sin(6 * s + g.ph + tau * 0.45 * calm));
    }
    const body = (s: number) => (0.55 + 1.5 * Math.pow(1 - s, 0.8)) * (0.45 + 0.55 * smooth(0, 0.12, s)) * (1 - 0.8 * smooth(0.7, 1, s));
    // A soft back curtain, a little wider and frilled; a finer front curtain over it.
    const back = pair[0];
    const front2 = pair[1];
    if (back) {
      back.setAttribute("d", ribbon(xs, ys, (s) => body(s) * 1.5 * (fa[Math.round(s * M)] ?? 1)));
      back.setAttribute("opacity", tintO);
    }
    if (front2) front2.setAttribute("d", ribbon(xs, ys, (s) => body(s) * 0.7 * (2 - (fa[Math.round(s * M)] ?? 1))));
  });
}

type Opts = { target: number | null; reduced: boolean; onComplete: (() => void) | undefined; wk: number };

export function BellBreathLoader({ progress, label = "Loading", size = 96, hue = "Medusa", showValue = false, onComplete, className = "", style }: BellBreathLoaderProps) {
  const reduced = useSyncExternalStore(subscribeReduced, getReduced, getReducedServer);
  const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
  const det = typeof progress === "number" && Number.isFinite(progress);
  const value = det ? clamp(progress, 0, 100, 0) : 0;
  const shown = Math.round(value);
  const sz = clamp(size, 32, 320, 96);
  const pal = PALETTES[hue] ?? PALETTES.Medusa;
  const name = typeof label === "string" && label.trim() ? label.trim().slice(0, 80) : "Loading";
  const k = Math.min(1.9, Math.max(1, 96 / sz));
  const full = sz >= 72;

  const rootRef = useRef<HTMLDivElement>(null);
  const opts = useRef<Opts>({ target: det ? value / 100 : null, reduced, onComplete, wk: k });
  const refresh = useRef<() => void>(() => {});

  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const one = <T extends Element>(sel: string) => root.querySelector(sel) as T | null;
    const svg = one<SVGSVGElement>("[data-bb=svg]");
    const fig = one<SVGGElement>("[data-bb=fig]");
    const bell = one<SVGGElement>("[data-bb=bell]");
    const light = one<SVGGElement>("[data-bb=light]");
    const glow = one<SVGEllipseElement>("[data-bb=glow]");
    const organ = one<SVGGElement>("[data-bb=organ]");
    const rim = one<SVGPathElement>("[data-bb=rim]");
    const aura = one<SVGEllipseElement>("[data-bb=aura]");
    const thinG = one<SVGGElement>("[data-bb=thin]");
    const oralG = one<SVGGElement>("[data-bb=oral]");
    if (!svg || !fig || !bell || !light || !glow || !organ || !rim || !aura || !thinG || !oralG) return;
    const pick = (g: Element, sel: string) => g.querySelector<SVGPathElement>(sel);
    const E: Els = {
      svg,
      fig,
      bell,
      light,
      glow,
      organ,
      rim,
      aura,
      thinG,
      oralG,
      shape: Array.from(root.querySelectorAll<SVGPathElement>("[data-bb=bs]")),
      thin: Array.from(root.querySelectorAll("[data-bb=tg]")).map((g) => ({ halo: pick(g, "[data-bb=th]"), body: pick(g, "[data-bb=tb]"), line: pick(g, "[data-bb=tc]") })),
      oral: Array.from(root.querySelectorAll("[data-bb=og]")).map((g) => Array.from(g.querySelectorAll("path"))),
    };

    let alive = true;
    let raf = 0;
    let onScreen = true;
    let tabShown = !document.hidden;
    const t0 = performance.now();
    const st = { last: t0, p: 0, born: t0, reappear: -1e9, done: false, doneAt: 0, finished: false, first: true };

    const still = () => {
      const o = opts.current;
      const p = o.target == null ? 0.58 : o.target;
      draw(E, 1.9, p, () => 0.14, 0.7, o.wk);
      svg.style.opacity = "1";
      svg.style.transform = "none";
    };

    /** One frame. Returns false when the loop may sleep. */
    const step = (now: number) => {
      const o = opts.current;
      const tau = (now - t0) / 1000;
      const dt = Math.min(0.1, Math.max(0, (now - st.last) / 1000));
      st.last = now;
      const glowBreath = 0.5 + 0.5 * Math.cos(TAU * (cyc(tau) - 0.55));
      const pT = o.target == null ? 0.34 + 0.34 * glowBreath : o.target;
      const isDone = o.target != null && o.target >= 1;
      if (isDone && !st.done) {
        st.done = true;
        st.doneAt = now;
        if (st.first) st.finished = true;
        else o.onComplete?.();
      } else if (!isDone && st.done) {
        st.done = false;
        st.finished = false;
        st.reappear = now;
        st.p = pT;
      }
      st.first = false;
      st.p += (pT - st.p) * (1 - Math.exp(-dt / 0.35));

      let vis = 1;
      let lift = 0;
      let cFn = (a: number) => pulse(cyc(tau - a * 1.15));
      if (st.done) {
        const u = clamp01((now - st.doneAt) / 600);
        if (st.finished) {
          vis = 0;
        } else {
          vis = 1 - smooth(0.12, 1, u);
          lift = -24 * (1 - Math.pow(1 - u, 3.5));
          cFn = (a: number) => pulse(u - a * 0.22);
          if (u >= 1) st.finished = true;
        }
      } else {
        const ent = Math.min(smooth(0, 1, (now - st.born) / 700), smooth(0, 1, (now - st.reappear) / 700));
        vis = ent;
        lift = 8 * (1 - ent);
      }
      draw(E, tau, st.p, cFn, 1, o.wk);
      svg.style.opacity = vis.toFixed(3);
      svg.style.transform = "translateY(" + lift.toFixed(2) + "px)";
      return !(st.done && st.finished);
    };

    const loop = (now: number) => {
      raf = 0;
      if (!alive) return;
      if (opts.current.reduced) {
        still();
        return;
      }
      if (step(now) && onScreen && tabShown) raf = requestAnimationFrame(loop);
    };
    const wake = () => {
      if (!alive) return;
      if (opts.current.reduced) {
        if (raf) cancelAnimationFrame(raf);
        raf = 0;
        still();
        return;
      }
      if (raf || !onScreen || !tabShown) return;
      st.last = performance.now();
      raf = requestAnimationFrame(loop);
    };
    refresh.current = wake;

    const io = new IntersectionObserver((entries) => {
      const e = entries[entries.length - 1];
      if (!e) return;
      onScreen = e.isIntersecting;
      if (onScreen) wake();
      else if (raf) {
        cancelAnimationFrame(raf);
        raf = 0;
      }
    });
    io.observe(root);
    const onVis = () => {
      tabShown = !document.hidden;
      if (tabShown) wake();
      else if (raf) {
        cancelAnimationFrame(raf);
        raf = 0;
      }
    };
    document.addEventListener("visibilitychange", onVis);
    wake();
    return () => {
      alive = false;
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
      io.disconnect();
      document.removeEventListener("visibilitychange", onVis);
    };
  }, []);

  useEffect(() => {
    opts.current = { target: det ? value / 100 : null, reduced, onComplete, wk: k };
    refresh.current();
  });

  const v = (n: number) => n * k;
  const sr: CSSProperties = { position: "absolute", width: 1, height: 1, margin: -1, padding: 0, overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap", border: 0 };
  const stop = (c: string, o: number, at: string) => <stop offset={at} style={{ stopColor: c.charAt(0) === "#" ? c : "var(--bb-" + c + ")", stopOpacity: o }} />;
  const rootStyle = {
    position: "relative",
    display: "inline-flex",
    flexDirection: "column",
    alignItems: "center",
    width: sz,
    color: "#eaf1f6",
    "--bb-core": pal.core,
    "--bb-bell": pal.bell,
    "--bb-rim": pal.rim,
    ...style,
  } as CSSProperties;
  const shape0 = DOME_LINE + scallops(0.1) + "Z";
  const tg = "url(#" + uid + "t)";

  return (
    <div
      ref={rootRef}
      role="progressbar"
      aria-label={name}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={det ? shown : undefined}
      aria-valuetext={det ? (shown >= 100 ? "Complete" : shown + " percent") : undefined}
      className={className}
      style={rootStyle}
    >
      <span style={sr}>{det ? name + ", " + shown + " percent" : name}</span>
      <svg data-bb="svg" aria-hidden="true" width={sz} height={sz * 1.65} viewBox="0 0 100 165" fill="none" style={{ display: "block", overflow: "visible", opacity: 0 }}>
        <defs>
          <linearGradient id={uid + "b"} x1="0" y1="0" x2="0" y2="1">
            {stop(GLASS, 0.02, "0")}
            {stop(GLASS, 0.05, "0.5")}
            {stop(GLASS, 0.15, "1")}
          </linearGradient>
          <linearGradient id={uid + "l"} x1="0" y1="0" x2="0" y2="1">
            {stop("core", 0, "0")}
            {stop("core", 0.26, "0.09")}
            {stop("bell", 0.14, "0.26")}
            {stop("core", 0.11, "0.6")}
            {stop("core", 0.08, "1")}
          </linearGradient>
          <radialGradient id={uid + "m"} cx="0.5" cy="0.5" r="0.5">
            {stop("rim", 0.24, "0")}
            {stop("rim", 0.1, "0.55")}
            {stop("rim", 0, "1")}
          </radialGradient>
          <radialGradient id={uid + "g"} cx="0.5" cy="0.5" r="0.5">
            {stop("core", 0.32, "0")}
            {stop("core", 0.1, "0.55")}
            {stop("core", 0, "1")}
          </radialGradient>
          <radialGradient id={uid + "a"} cx="0.5" cy="0.5" r="0.5">
            {stop("bell", 0.1, "0")}
            {stop("bell", 0.035, "0.6")}
            {stop("bell", 0, "1")}
          </radialGradient>
          <linearGradient id={uid + "e"} gradientUnits="userSpaceOnUse" x1="0" y1="5" x2="0" y2="52">
            {stop(GLASS, 0.1, "0")}
            {stop(GLASS, 0.4, "0.55")}
            {stop(GLASS, 0.9, "1")}
          </linearGradient>
          <linearGradient id={uid + "t"} gradientUnits="userSpaceOnUse" x1="0" y1="50" x2="0" y2="160">
            {stop(GLASS, 0.5, "0")}
            {stop("bell", 0.46, "0.3")}
            {stop("bell", 0.36, "0.7")}
            {stop("bell", 0.28, "1")}
          </linearGradient>
          <linearGradient id={uid + "o"} gradientUnits="userSpaceOnUse" x1="0" y1="33" x2="0" y2="118">
            {stop("core", 0.16, "0")}
            {stop("core", 0.07, "0.45")}
            {stop("core", 0, "1")}
          </linearGradient>
          <linearGradient id={uid + "w"} gradientUnits="userSpaceOnUse" x1="0" y1="33" x2="0" y2="118">
            {stop("#eaf1f6", 0.2, "0")}
            {stop("#eaf1f6", 0.08, "0.45")}
            {stop("#eaf1f6", 0, "1")}
          </linearGradient>
          <filter id={uid + "f"} filterUnits="userSpaceOnUse" x="26" y="18" width="48" height="30">
            <feGaussianBlur stdDeviation="2" />
          </filter>
          <clipPath id={uid + "c"}>
            <path data-bb="bs" d={shape0} />
          </clipPath>
        </defs>
        <g data-bb="fig">
          <ellipse data-bb="aura" cx="50" cy="32" rx="48" ry="40" fill={"url(#" + uid + "a)"} />
          <g data-bb="thin">
            {THIN.map((g, i) => (
              <g key={i} data-bb="tg">
                <path data-bb="th" fill={tg} fillOpacity="0.07" />
                <path data-bb="tb" fill={tg} />
                {full && <path data-bb="tc" fill="none" stroke={tg} strokeWidth="0.9" strokeLinecap="round" strokeOpacity="0.3" strokeDasharray="0 7.5" />}
              </g>
            ))}
          </g>
          <g data-bb="oral">
            {ORAL.map((g, i) => (
              <g key={i} data-bb="og">
                <path fill={"url(#" + uid + "o)"} />
                <path fill={"url(#" + uid + "w)"} strokeWidth={v(0.25)} strokeLinejoin="round" stroke="#eaf1f6" strokeOpacity="0.1" />
              </g>
            ))}
          </g>
          <g data-bb="bell">
            <path data-bb="bs" d={shape0} fill={"url(#" + uid + "b)"} />
            <g clipPath={"url(#" + uid + "c)"}>
              <ellipse cx="50" cy="42" rx="24" ry="8" fill="#000" fillOpacity="0.12" />
              <path d={MOUTH} fill="#000" fillOpacity="0.2" />
              <g data-bb="light" transform="translate(0 60)">
                <rect x="-5" y="0" width="110" height="84" fill={"url(#" + uid + "l)"} />
                <ellipse cx="50" cy="1.5" rx="52" ry="6" fill={"url(#" + uid + "m)"} />
              </g>
              <ellipse data-bb="glow" cx="50" cy="35" rx="30" ry="22" fill={"url(#" + uid + "g)"} opacity="0" />
              {full && <path d={INNER} strokeWidth={v(0.5)} stroke={GLASS} strokeOpacity="0.1" />}
              {full && CANALS.map((d, i) => <path key={i} d={d} strokeWidth={v(0.35)} stroke={GLASS} strokeOpacity="0.07" />)}
            </g>
            <g data-bb="organ" opacity="0.3" filter={"url(#" + uid + "f)"}>
              {GONADS.map((o, i) => (
                <path key={i} d={KIDNEY} transform={"translate(" + o.x + " " + o.y + ") rotate(" + o.r + ") scale(" + o.s + ")"} style={{ fill: "var(--bb-core)", fillOpacity: 0.5 }} />
              ))}
            </g>
            <path d={DOME_LINE} strokeWidth={v(3.2)} strokeLinecap="round" stroke={"url(#" + uid + "e)"} strokeOpacity="0.1" />
            <path d={DOME_LINE} strokeWidth={v(1.5)} strokeLinecap="round" stroke={"url(#" + uid + "e)"} strokeOpacity="0.22" />
            <path d={DOME_LINE} strokeWidth={v(0.5)} strokeLinecap="round" stroke={"url(#" + uid + "e)"} strokeOpacity="0.6" />
            <path d="M21 50Q50 45.4 79 50" strokeWidth={v(0.4)} stroke={GLASS} strokeOpacity="0.14" />
            <path data-bb="rim" d={"M79 50" + scallops(0.1)} strokeWidth={v(0.5)} strokeLinecap="round" strokeLinejoin="round" style={{ stroke: "var(--bb-rim)", strokeOpacity: 0.4 }} />
            <path d={GLINT_A} strokeWidth={v(0.9)} strokeLinecap="round" stroke="#d9ecf8" strokeOpacity="0.42" />
            <path d={GLINT_B} strokeWidth={v(0.7)} strokeLinecap="round" stroke="#d9ecf8" strokeOpacity="0.22" />
          </g>
        </g>
      </svg>
      {(showValue || reduced) && (
        <div
          aria-hidden="true"
          style={{
            marginTop: 4,
            fontFamily: 'ui-monospace, "Geist Mono", SFMono-Regular, Menlo, Consolas, monospace',
            fontSize: 10,
            lineHeight: 1.4,
            letterSpacing: "0.14em",
            textTransform: "uppercase",
            fontVariantNumeric: "tabular-nums",
            whiteSpace: "nowrap",
            opacity: 0.62,
          }}
        >
          {det ? shown + "%" : name}
        </div>
      )}
    </div>
  );
}
Notes

About this component

Most loaders are a spinner or a bar: a promise that something is happening, with no sense of how much. This one is a single jellyfish drawn in SVG, and the progress is the light inside it. As the number climbs, light rises through the translucent bell from the rim, the faint ring of organs ignites when it arrives, and the tentacles grow longer, each at its own pace. Give it no value and it breathes instead, on a five second cycle: one quick contraction, a slow release, the tentacles trailing the bell with a visible delay and swaying on periods that never line up. At one hundred it makes a single exhale, lifts 24 px and fades over 600 milliseconds, then stops drawing altogether. It is a real progressbar with a text fallback, four specimen hues and a size from 48 to 160 px. Under reduced motion it becomes one still bell with a numeric readout. Use it for uploads, route changes and long form submits, never as a splash that holds a page hostage.

Curator’s note

The one anomaly is that progress is measured in light, in a body that has no use for a number. Everything else is obedient: the bell is a translucent shell with a faint organ ring, the tentacles are a travelling wave that trails the contraction, and the exhale at 100 is one breath, not a flourish. It is plain SVG and one small loop that sleeps off screen, so it is a loader you can ship in an upload dialog rather than a demo.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

NewPro

One large jellyfish, centred low in a black void, lit only from inside. The bell is dark transparent glass with gonads, canals and frilled oral arms burning through it. Touch a tentacle and light runs up it to the bell; click and it jets upward and sinks back.

sectionwebgl3d
NewPro

A bloom of tiny moon jellyfish in a near-black void, with no water in the picture: 600 by default, up to 2000 on the count prop, each one small and far like a bloom seen from a distance. The glass is colourless; the only colour is in the organs glowing inside. The swarm has read your page: it drifts around the content, keeps clear of it, and softly lights the words nearest to it. Move the pointer and a current runs through the bloom; click and a ring of it crosses the swarm.

backgroundwebgljellyfish
New

Bead Button

components

A single bead of water on dark hydrophobic glass is the button. It magnifies the etched plate behind it and shows it upside down, the way real water does, while the label stays upright and crisp. The pointer's approach leans it, a press flattens it and pinches off a satellite droplet that re-merges, and it never runs away.

buttonwaterdroplet