Domphy

loaderSet

A Loaders block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call loaderSet() with no arguments for a working demo, or edit the code below live.

Implementation notes

Single exported factory (loaderSet(props)) covering all 5 reference variants via a variant prop ('simple'|'shimmer'|'compact'|'svg'|'glitch'); zero-arg call renders a labeled gallery of all five, matching the package's 'call with no args -> working demo' convention. Simple/compact dots bob/overlap-pulse via a per-dot sine-wave requestAnimationFrame loop (not CSS keyframes, per spec); shimmer sweeps a highlight wave across per-character spans via rAF; the SVG lightning-bolt draws itself via a static (color-free) stroke-dasharray @keyframes loop and crossfades fill between two theme colors (white/neutral shift-0 and highlight-family base, i.e. yellow) via a periodic setInterval + CSS transition: fill rather than a non-reactive baked-color @keyframes (keyframe step values can't hold reactive functions); glitch jitters two theme-colored duplicate text layers (success/secondary families substituting for literal green/purple, mixBlendMode: screen) on a fast setInterval. Simplification: skips the reference's 'perspective wrapper 3D wobble' on the glitch base text (translate jitter only, no 3D tilt) -- a minor decorative omission, not structural. All colors are theme-token substitutes for the reference's literal hex palette, which the spec's own researchNote says to treat as non-binding. Verified: tsc clean, doctor 0 diagnostics, all tests pass (including a leaked-timer check on unmount for every variant) -- also caught and fixed a stray non-breaking-space literal that had crept into the shimmer variant's per-character split during authoring.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Loader" — clean-room reimplementation of the reference
// page's five small drop-in loading indicators (bouncing dots, shimmering
// text, an overlapping-dot cluster, a charging SVG glyph, and glitching
// text). Implemented purely from the block's public functional/visual spec —
// no upstream Aceternity source was viewed or copied.
//
// Every variant's continuous motion is JS-driven (a `requestAnimationFrame`
// loop or a `setInterval`, started in `_onMount`, torn down in `_onRemove`),
// per the spec's own note that none of these loop via plain CSS
// `@keyframes`. `loaderSet(props)` is the package's single exported factory:
// pass `variant` to render just one indicator (the intended drop-into-a-
// button/screen usage); call with no arguments to render a labeled gallery
// of all five, which is what the zero-arg "working demo" convention this
// package uses for docs screenshots produces here.
import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { small } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";

export type LoaderVariant = "simple" | "shimmer" | "compact" | "svg" | "glitch";

export interface LoaderSetProps {
  /** Which single indicator to render. Omit to render a demo gallery of all five. */
  variant?: LoaderVariant;
  /** Text content for the `"shimmer"`/`"glitch"` variants. */
  text?: string;
  /** Theme color family. Defaults to `"neutral"` for every variant. */
  color?: ThemeColor;
}

// ─── Simple / Compact (bouncing / overlapping dots) ──────────────────────────

function dotGlyph(): DomphyElement<"span"> {
  return {
    span: null,
    ariaHidden: "true",
    // Decorative dot with no text of its own — exempt from the missing-color
    // contract, matching `meteors.ts`'s own dot span.
    _doctorDisable: "missing-color",
    style: {
      display: "inline-block",
      flexShrink: "0",
      width: themeSpacing(4),
      height: themeSpacing(4),
      borderRadius: "50%",
      boxSizing: "border-box",
      border: (listener: Listener) => `1px solid ${themeColor(listener, "shift-6")}`,
      // A subtle top-light/bottom-dark gradient fill (not `backgroundColor`,
      // which the `tone-background-inherit` doctor rule only checks) —
      // matches `pulsatingButton.ts`'s own gradient-over-solid-color trick.
      backgroundImage: (listener: Listener) =>
        `linear-gradient(to bottom, ${themeColor(listener, "shift-2")}, ${themeColor(listener, "shift-7")})`,
    } as StyleObject,
  } as DomphyElement<"span">;
}

/** Starts a per-dot oscillating loop (sine wave, phase-offset per index) that
 * writes a `translate(x, y)` transform directly to each dot's own DOM node
 * every animation frame. `axis` picks vertical bob (`"simple"`) vs
 * horizontal overlap-pulse (`"compact"`). */
function oscillateDots(node: ElementNode, axis: "x" | "y", amplitudePx: number, periodMs: number) {
  const container = node.domElement as HTMLElement | null;
  if (!container) return () => {};
  const dots = Array.from(container.children) as HTMLElement[];
  let animationFrame = 0;
  const startTime = performance.now();

  const tick = (now: number) => {
    const elapsed = now - startTime;
    dots.forEach((dot, index) => {
      const phase = (index / dots.length) * Math.PI * 2;
      const offset = Math.sin((elapsed / periodMs) * Math.PI * 2 + phase) * amplitudePx;
      dot.style.transform = axis === "y" ? `translateY(${offset.toFixed(2)}px)` : `translateX(${offset.toFixed(2)}px)`;
    });
    animationFrame = requestAnimationFrame(tick);
  };
  animationFrame = requestAnimationFrame(tick);

  return () => {
    if (animationFrame) cancelAnimationFrame(animationFrame);
    dots.forEach((dot) => (dot.style.transform = ""));
  };
}

function simpleLoader(): DomphyElement<"div"> {
  return {
    div: [dotGlyph(), dotGlyph(), dotGlyph()],
    role: "status",
    ariaLabel: "loading",
    style: { display: "inline-flex", alignItems: "center", gap: themeSpacing(2) },
    _onMount: (node: ElementNode) => node.setMetadata("stopOscillation", oscillateDots(node, "y", 4, 900)),
    _onRemove: (node: ElementNode) => (node.getMetadata("stopOscillation") as (() => void) | undefined)?.(),
  };
}

function compactLoader(): DomphyElement<"div"> {
  return {
    div: [dotGlyph(), dotGlyph(), dotGlyph()],
    role: "status",
    ariaLabel: "loading",
    style: {
      display: "inline-flex",
      alignItems: "center",
      // Heavy negative overlap instead of the simple variant's even gap.
      "& > *:not(:first-child)": { marginInlineStart: themeSpacing(-2.5) },
    } as StyleObject,
    _onMount: (node: ElementNode) => node.setMetadata("stopOscillation", oscillateDots(node, "x", 3, 1100)),
    _onRemove: (node: ElementNode) => (node.getMetadata("stopOscillation") as (() => void) | undefined)?.(),
  };
}

// ─── Shimmer (sweeping-highlight text) ────────────────────────────────────────

function shimmerLoader(text: string): DomphyElement<"span"> {
  const characters = text.split("");
  return {
    span: characters.map((character, index) => ({
      span: character,
      _key: `shimmer-char-${index}`,
      style: { display: "inline-block", opacity: 0.35, color: (listener: Listener) => themeColor(listener, "shift-9") } as StyleObject,
    })),
    role: "status",
    ariaLabel: text,
    style: { display: "inline-block", whiteSpace: "pre" },
    _onMount: (node: ElementNode) => {
      const container = node.domElement as HTMLElement | null;
      if (!container) return;
      const spans = Array.from(container.children) as HTMLElement[];
      let animationFrame = 0;
      const startTime = performance.now();
      const waveWidth = 3; // characters the bright highlight spans at once
      const cycleMs = 1400;

      const tick = (now: number) => {
        const progress = ((now - startTime) % cycleMs) / cycleMs;
        const wavePosition = progress * (spans.length + waveWidth * 2) - waveWidth;
        spans.forEach((span, index) => {
          const distance = Math.abs(index - wavePosition);
          const intensity = Math.max(0, 1 - distance / waveWidth);
          span.style.opacity = String(0.35 + intensity * 0.65);
        });
        animationFrame = requestAnimationFrame(tick);
      };
      animationFrame = requestAnimationFrame(tick);
      node.setMetadata("stopShimmer", () => cancelAnimationFrame(animationFrame));
    },
    _onRemove: (node: ElementNode) => (node.getMetadata("stopShimmer") as (() => void) | undefined)?.(),
  };
}

// ─── SVG (charging lightning-bolt) ────────────────────────────────────────────

const BOLT_PATH = "M13 2 L4 14 H11 L9 22 L20 9 H13 L13 2 Z";

function svgLoader(): DomphyElement<"span"> {
  return {
    span: [
      {
        svg: [
          {
            path: null,
            d: BOLT_PATH,
            fill: "none",
            stroke: "currentColor",
            strokeWidth: "1.2",
            strokeLinejoin: "round",
            strokeLinecap: "round",
            pathLength: "1",
            style: {
              strokeDasharray: "1",
              animation: "loader-set-bolt-draw 1.8s linear infinite",
              "@keyframes loader-set-bolt-draw": {
                "0%": { strokeDashoffset: "1" },
                "60%,100%": { strokeDashoffset: "0" },
              },
            } as StyleObject,
          } as DomphyElement<"path">,
        ],
        viewBox: "0 0 24 24",
        role: "img",
        ariaHidden: "true",
        style: { width: "100%", height: "100%" },
      } as DomphyElement<"svg">,
    ],
    role: "status",
    ariaLabel: "loading",
    style: { display: "inline-flex", width: themeSpacing(20), height: themeSpacing(20), color: (listener: Listener) => themeColor(listener, "shift-6") } as StyleObject,
    _onMount: (node: ElementNode) => {
      const path = (node.domElement as HTMLElement | null)?.querySelector("path") as SVGPathElement | null;
      if (!path) return;
      path.style.transition = "fill 900ms ease-in-out";
      let showBright = true;
      const applyFill = () => {
        path.style.fill = showBright ? themeColor(node, "shift-0", "neutral") : themeColor(node, "base", "highlight");
        showBright = !showBright;
      };
      applyFill();
      const timer = setInterval(applyFill, 900);
      node.setMetadata("stopBoltFill", () => clearInterval(timer));
    },
    _onRemove: (node: ElementNode) => (node.getMetadata("stopBoltFill") as (() => void) | undefined)?.(),
  };
}

// ─── Glitch (chromatic-aberration text) ───────────────────────────────────────

function glitchLoader(text: string): DomphyElement<"span"> {
  const duplicateLayer = (family: ThemeColor, key: string): DomphyElement<"span"> => ({
    span: text,
    _key: key,
    ariaHidden: "true",
    style: {
      position: "absolute",
      inset: 0,
      color: (listener: Listener) => themeColor(listener, "shift-8", family),
      opacity: 0.7,
      mixBlendMode: "screen",
    } as StyleObject,
  });

  return {
    span: [
      duplicateLayer("success", "glitch-green"),
      duplicateLayer("secondary", "glitch-purple"),
      { span: text, style: { position: "relative" } },
    ],
    role: "status",
    ariaLabel: text,
    style: { position: "relative", display: "inline-block", color: (listener: Listener) => themeColor(listener, "shift-9") } as StyleObject,
    _onMount: (node: ElementNode) => {
      const container = node.domElement as HTMLElement | null;
      if (!container) return;
      const greenLayer = container.querySelector('[aria-hidden="true"]:nth-child(1)') as HTMLElement | null;
      const purpleLayer = container.querySelector('[aria-hidden="true"]:nth-child(2)') as HTMLElement | null;
      if (!greenLayer || !purpleLayer) return;

      const jitter = () => {
        greenLayer.style.transform = `translate(${(Math.random() - 0.5) * 2.4}px, ${(Math.random() - 0.5) * 1.4}px)`;
        purpleLayer.style.transform = `translate(${(Math.random() - 0.5) * 2.4}px, ${(Math.random() - 0.5) * 1.4}px)`;
      };
      jitter();
      const timer = setInterval(jitter, 120);
      node.setMetadata("stopGlitch", () => clearInterval(timer));
    },
    _onRemove: (node: ElementNode) => {
      const stop = node.getMetadata("stopGlitch") as (() => void) | undefined;
      stop?.();
    },
  };
}

// ─── Gallery (zero-arg demo) ───────────────────────────────────────────────────

function galleryEntry(labelText: string, content: DomphyElement): DomphyElement<"div"> {
  return {
    div: [content, { small: labelText, $: [small()] }],
    style: { display: "flex", flexDirection: "column", alignItems: "center", gap: themeSpacing(2) },
  };
}

function loaderGallery(): DomphyElement<"div"> {
  return {
    div: [
      galleryEntry("Simple", simpleLoader()),
      galleryEntry("Shimmer", shimmerLoader("Generating chat...")),
      galleryEntry("Compact", compactLoader()),
      galleryEntry("SVG", svgLoader()),
      galleryEntry("Glitch", glitchLoader("Loading...")),
    ],
    style: { display: "flex", flexWrap: "wrap", alignItems: "flex-start", gap: themeSpacing(8) },
  };
}

/**
 * One of five small drop-in loading indicators (`"simple"` bouncing dots,
 * `"shimmer"` sweeping-highlight text, `"compact"` overlapping dots, `"svg"` a
 * charging lightning-bolt glyph, `"glitch"` chromatic-aberration text). Pass
 * `variant` to render a single indicator for embedding in a button or loading
 * screen; call with no arguments for a working demo — a labeled gallery of
 * all five.
 */
function loaderSet(props: LoaderSetProps = {}): DomphyElement {
  const text = props.text ?? (props.variant === "glitch" ? "Loading..." : "Generating chat...");

  switch (props.variant) {
    case "simple":
      return simpleLoader();
    case "compact":
      return compactLoader();
    case "shimmer":
      return shimmerLoader(text);
    case "svg":
      return svgLoader();
    case "glitch":
      return glitchLoader(text);
    default:
      return loaderGallery();
  }
}

export { loaderSet };

← Back to Aceternity UI catalog