Skip to main content

ns-ui

Glory Hole Cycle

A 'still actively processing' status chip whose small canvas glow rises fast and cools slow on a fixed 4.0s beat, modeled on a glassblower's furnace reheat cycle instead of a generic pulsing dot.

Use when a process is still actively running right now and needs one continuous ambient chip beside a filename, job row, or sync target — a single glow rising sharply then cooling slowly on a steady beat reads as 'still working' without a spinner or a swapped icon; pick status-glyph-cadence instead when the surface has several distinct TASK states (working/searching/blocked/done) that each need their own motion cadence rather than one continuous 'still active' read, or status-sphere-dots when the indicator also has to hold up at showpiece scale (up to ~200px) rather than only as a small inline chip.

Install

npx shadcn add https://design.helpmarq.com/r/glory-hole-cycle.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Source
registry/core/glory-hole-cycle/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// GloryHoleCycle — a "still actively processing" status chip modeled on a
// glassblower's glory-hole reheat cycle, not a generic pulsing dot. Real hot
// glass cools the instant it leaves the furnace mouth, so a gaffer plunges
// the piece back in on a steady beat to bring it back to working heat, then
// pulls it out to work it while hot — repeat for as long as the piece is
// still being formed. The chip's small circular canvas plays that exact
// rhythm: a fast reheat ramp (cubic ease-out, 0.7s) up to peak "hot" then a
// slow radiative cool (exponential decay, tau=1.1s) back down, on a fixed,
// non-negotiable 4.0s beat for as long as the process is active. Both ends
// of the sweep interpolate luminance ONLY — the disc's colour is a straight
// RGB lerp between the live var(--ns-muted) and var(--foreground) values (no
// hue, ever, and never var(--ns-accent), which is interaction chrome only).
// A subtle 1px inner ring pulses opacity in lockstep with the same
// rise/decay curve, reading as a discrete "reinsertion" event on every beat
// rather than a continuous shimmer.
//
// Tokens are read once via getComputedStyle(document.documentElement) before
// any paint, and re-read on every class-attribute mutation of <html> (a
// MutationObserver) so a live theme toggle repaints the next frame with the
// new muted/foreground pair — no literal colour anywhere, including the
// gradient's alpha fallback stops. The canvas is a fixed 36x36 CSS-px circle
// (DPR-scaled on mount and on resize, since that's the one thing that can
// change this chip's device pixel ratio without remounting it) — the spec
// for this exact mechanic hard-codes that size rather than deriving it from
// a container, since the chip is always rendered at this one physical scale
// next to a text label, never stretched to fill a card.
//
// The animation loop pauses via IntersectionObserver when the chip scrolls
// off-screen and resumes on a fresh cycle boundary, and is cancelled outright
// on unmount along with both observers — no leaked rAF, no leaked observer.
// prefers-reduced-motion skips the loop entirely and paints one frozen frame
// at t=0.7s into the cycle (L=Lmax, ring=0.4) — the peak of the reheat ramp,
// the single frame that most clearly reads "hot" rather than an ambiguous
// mid-decay grey.
// ---------------------------------------------------------------------------

const CYCLE_MS = 4000; // fixed, non-negotiable cadence
const RAMP_MS = 700; // reheat ramp: cubic ease-out, Lmin -> Lmax
const DECAY_TAU_MS = 1100; // cool decay: exponential, tau = 1.1s
const L_MIN = 0.18; // near --ns-muted
const L_MAX = 0.92; // near --foreground
const RING_MIN = 0.15;
const RING_MAX = 0.4;
const CANVAS_CSS = 36; // fixed 36x36 CSS px, per spec

type Rgb = [number, number, number];

function parseColor(input: string): Rgb {
  const trimmed = input.trim();
  const fn = trimmed.match(/rgba?\(([^)]+)\)/i);
  if (fn) {
    const parts = fn[1]!.split(",").map((s) => parseFloat(s.trim()));
    return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
  }
  const hex = trimmed.replace("#", "");
  const full = hex.length === 3 ? hex.split("").map((c) => c + c).join("") : hex;
  const num = parseInt(full || "808080", 16);
  return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
}

function lerp(a: number, b: number, t: number): number {
  return a + (b - a) * t;
}

// cubic ease-out, 0..1 -> 0..1
function easeOutCubic(t: number): number {
  const p = 1 - t;
  return 1 - p * p * p;
}

interface GlowState {
  L: number;
  ring: number;
}

// where the cycle is at msInCycle: fast eased rise for the reheat ramp, then
// an exponential radiative decay for the rest of the 4.0s beat. The ring
// opacity rides the identical curve mapped onto its own 0.15-0.4 range, so
// the two read as one discrete "reinsertion" pulse rather than two loops.
function stateAt(msInCycle: number): GlowState {
  if (msInCycle < RAMP_MS) {
    const p = easeOutCubic(msInCycle / RAMP_MS);
    return { L: lerp(L_MIN, L_MAX, p), ring: lerp(RING_MIN, RING_MAX, p) };
  }
  const k = Math.exp(-(msInCycle - RAMP_MS) / DECAY_TAU_MS);
  return {
    L: L_MIN + (L_MAX - L_MIN) * k,
    ring: RING_MIN + (RING_MAX - RING_MIN) * k,
  };
}

export interface GloryHoleCycleProps {
  /** status text rendered beside the glow and announced on the live region. default "Processing". */
  label?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function GloryHoleCycle({
  label = "Processing",
  className = "",
}: GloryHoleCycleProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const ringRef = useRef<HTMLDivElement>(null);
  const mutedRgb = useRef<Rgb>([128, 128, 128]);
  const fgRgb = useRef<Rgb>([0, 0, 0]);

  useEffect(() => {
    const canvas = canvasRef.current;
    const ring = ringRef.current;
    if (!canvas || !ring) return;

    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    const readTokens = () => {
      const cs = getComputedStyle(document.documentElement);
      mutedRgb.current = parseColor(cs.getPropertyValue("--ns-muted") || "#808080");
      fgRgb.current = parseColor(cs.getPropertyValue("--foreground") || "#000000");
    };

    // no paint before the first token read
    readTokens();

    let dpr = Math.max(1, window.devicePixelRatio || 1);

    const sizeCanvas = () => {
      dpr = Math.max(1, window.devicePixelRatio || 1);
      canvas.width = Math.round(CANVAS_CSS * dpr);
      canvas.height = Math.round(CANVAS_CSS * dpr);
      canvas.style.width = `${CANVAS_CSS}px`;
      canvas.style.height = `${CANVAS_CSS}px`;
    };
    sizeCanvas();

    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const paint = ({ L, ring: ringOpacity }: GlowState) => {
      const t = (L - L_MIN) / (L_MAX - L_MIN);
      const [mr, mg, mb] = mutedRgb.current;
      const [fr, fg, fb] = fgRgb.current;
      const r = Math.round(lerp(mr, fr, t));
      const g = Math.round(lerp(mg, fg, t));
      const b = Math.round(lerp(mb, fb, t));

      const cx = CANVAS_CSS / 2;
      const cy = CANVAS_CSS / 2;
      const radius = Math.min(CANVAS_CSS, CANVAS_CSS) * 0.6;

      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, CANVAS_CSS, CANVAS_CSS);

      const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
      grad.addColorStop(0, `rgba(${r}, ${g}, ${b}, 1)`);
      grad.addColorStop(0.7, `rgba(${r}, ${g}, ${b}, 0.55)`);
      grad.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);

      ctx.fillStyle = grad;
      ctx.beginPath();
      ctx.arc(cx, cy, radius, 0, Math.PI * 2);
      ctx.fill();

      ring.style.opacity = ringOpacity.toFixed(3);
      ring.style.borderColor = `rgb(${fr}, ${fg}, ${fb})`;
    };

    let raf = 0;
    let startTime = 0;

    const loop = (now: number) => {
      if (startTime === 0) startTime = now;
      const msInCycle = (now - startTime) % CYCLE_MS;
      paint(stateAt(msInCycle));
      raf = requestAnimationFrame(loop);
    };

    if (reduced) {
      paint(stateAt(RAMP_MS)); // frozen at the peak of the reheat ramp
    } else {
      raf = requestAnimationFrame(loop);
    }

    const mo = new MutationObserver(() => {
      readTokens();
      if (reduced) paint(stateAt(RAMP_MS));
      // animated path just picks the fresh tokens up on its next rAF frame
    });
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    let io: IntersectionObserver | null = null;
    if (!reduced) {
      io = new IntersectionObserver((entries) => {
        const visible = entries[0]?.isIntersecting ?? true;
        if (visible && !raf) {
          startTime = 0; // resume clean on a fresh cycle boundary
          raf = requestAnimationFrame(loop);
        } else if (!visible && raf) {
          cancelAnimationFrame(raf);
          raf = 0;
        }
      });
      io.observe(canvas);
    }

    const onResize = () => sizeCanvas();
    window.addEventListener("resize", onResize);

    return () => {
      if (raf) cancelAnimationFrame(raf);
      mo.disconnect();
      io?.disconnect();
      window.removeEventListener("resize", onResize);
    };
  }, []);

  return (
    <span
      role="status"
      aria-live="polite"
      className={`inline-flex shrink-0 items-center gap-2 rounded-full border border-border bg-surface py-1 pl-1 pr-3 ${className}`}
    >
      <span className="relative inline-flex h-9 w-9 shrink-0 items-center justify-center">
        <canvas ref={canvasRef} aria-hidden="true" className="block" />
        <span
          ref={ringRef}
          aria-hidden="true"
          className="pointer-events-none absolute inset-[3px] rounded-full"
          style={{ borderWidth: 1, borderStyle: "solid", opacity: RING_MIN }}
        />
      </span>
      <span className="text-sm text-foreground">{label}</span>
    </span>
  );
}
Build spec

A passive 'still actively processing' status chip — the pulsing-dot pattern's replacement — built on glassblowing's glory-hole reheat cycle: hot glass cools the instant it leaves the furnace mouth, so a gaffer plunges the piece back into the glory hole on a steady beat to bring it back to working heat, then withdraws it to work while hot, repeating for as long as the piece is still being formed. The chip is a rounded pill (rounded-full, border-border 1px ring, bg-surface fill) containing a fixed 36x36 CSS-px circular <canvas> (DPR-scaled on mount and window resize) plus a text label. The canvas draws one soft radial gradient disc, no grid, radius fixed at 60% of the canvas's smaller (36px) dimension, three gradient stops (opaque center, 55% alpha at 70%, 0% alpha at the edge). Colour is never a literal: on mount, and on every class-attribute mutation of document.documentElement (a MutationObserver, for live theme toggles), the component reads var(--ns-muted) and var(--foreground) via getComputedStyle and parses them to RGB; every frame's disc colour is a straight RGB lerp between those two parsed values driven by a scalar luminance L, never a hue shift and never var(--ns-accent) (interaction chrome only, never the climactic moment). The animation is a fixed, non-negotiable 4.0s cycle with two phases: a 0.7s reheat ramp (cubic ease-out) carrying L from 0.18 (near --ns-muted) up to 0.92 (near --foreground), then a 3.3s cool decay, an exponential L(t) = Lmin + (Lmax-Lmin) * exp(-t/tau) with tau = 1.1s, so it visibly dims fast then trails slowly like real radiative cooling rather than a linear fade. A subtle 1px inner ring (a plain DOM span, border colour set to the live --foreground RGB) pulses opacity across the identical 0.15-0.4 range on the identical rise/decay curve, so ring and disc read as one discrete 'reinsertion' pulse each beat rather than a continuous shimmer. The whole thing is one requestAnimationFrame loop computing phase from (now - startTime) % 4000ms; it is paused via an IntersectionObserver while the chip is scrolled off-screen and resumed on a fresh cycle boundary rather than a stale phase, and cancelled along with both observers on unmount. Nothing here restarts or accelerates on hover/focus — the cycle represents real elapsed process time, not UI feedback, so if a dismiss affordance is ever added beside it, it must leave the glow's phase untouched. prefers-reduced-motion skips the rAF loop entirely and paints exactly one frozen frame at t=0.7s into the cycle (L=Lmax, ring opacity 0.4) — the peak 'hot' frame, deliberately not an ambiguous mid-decay grey. Light theme is the harder case: because --ns-muted sits noticeably lighter than raw black there, the decay tail's low end is checked against --surface first so the last ~1.5s of every cycle still reads as 'dim, not gone' rather than disappearing into the chip background. Props: label (status text rendered beside the glow and announced via the chip's own role=status aria-live=polite region, default 'Processing'), className. Zero dependencies, no WebGL.

Props

PropTypeDefaultDescription
label?string"Processing"status text rendered beside the glow and announced on the live region. default "Processing".
className?stringextra classes merged onto the rendered root element