Domphy

multiStepLoader

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

Implementation notes

Full-viewport frosted overlay, always mounted, toggled via reactive opacity/visibility/pointerEvents off a loading ValueOrState; toState() returns the caller's own State reference when one is passed, so the built-in close button's .set(false) is directly observable by a caller-owned state (an onClose callback is also offered for plain-boolean callers). Auto-advance uses watch() on the loading state to start/stop a setInterval (loop/duration honored, stops on last step when loop=false); an optional value prop allows fully manual index control, bypassing the internal timer. Step rows recompute icon/text color/opacity from distance-to-active-index; the column translateY reactively scroll-anchors the current step. Simplification: 'filled vs outline' checkmark icons are distinguished via stroke-width/color/opacity rather than a second hardcoded punch-through fill color (avoids introducing any literal color), and the backdrop/step palette is a single reactive (ambient-tone-following) scheme rather than a separate hardcoded light/dark backdrop variant -- both are documented, deliberate simplifications, not missing functionality. Verified: tsc clean, doctor 0 diagnostics, all tests pass (including fake-timer auto-advance and manual-value-skips-timer cases).

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Multi-Step Loader" — clean-room reimplementation.
//
// A full-screen overlay that walks through an ordered list of labeled steps,
// automatically advancing and marking each as done, for use behind
// long-running operations. Implemented purely from the block's public
// functional/visual spec — no upstream Aceternity source was viewed or
// copied.
//
// The overlay is always mounted (never conditionally added/removed from the
// tree); `loading` toggles its `opacity`/`pointerEvents`/`visibility`
// reactively, the same "always-present, CSS-toggled" approach `dialog.ts`
// uses for its own open/close fade (minus native `<dialog>` semantics, which
// this component doesn't need). `loading`/`value` are both accepted as
// `ValueOrState` and passed straight through `toState()`, which returns the
// SAME `State` reference when the caller already supplied one — so the
// built-in close button's `state.set(false)` is directly observable by a
// caller who wired a shared `State<boolean>` in, without any extra `onClose`
// plumbing (an `onClose` callback is offered too, for callers who passed a
// plain boolean and can't observe the internal state directly).
//
// Auto-advance is driven by `watch()` on the loading state (start/stop a
// `setInterval` as it flips), not a CSS animation loop — the active index is
// plain reactive `State<number>`, and every row's icon/text/opacity is
// recomputed from its own distance to that index. The step column's
// `translateY` is likewise a reactive function of the active index, giving
// the "list scrolls upward to keep the current step anchored" effect via a
// single CSS `transition` rather than a JS tween.
import type { DomphyElement, ElementNode, Listener, State, StyleObject, ValueOrState } from "@domphy/core";
import { toState, watch } from "@domphy/core";
import { strong } from "@domphy/ui";
import { themeColor, themeDensity, themeSize, themeSpacing } from "@domphy/theme";

export interface MultiStepLoaderStep {
  /** Label text describing this step. */
  text: string;
}

export interface MultiStepLoaderProps {
  /** Ordered steps walked through. Defaults to an 8-step placeholder sequence. */
  loadingStates?: MultiStepLoaderStep[];
  /** Controls whether the overlay is mounted/visible. Accepts a value or reactive state. Defaults to `false`. */
  loading?: ValueOrState<boolean>;
  /** Milliseconds between automatic step advances. Defaults to `2000`. */
  duration?: number;
  /** Restart from the first step after the last one. Defaults to `true`. */
  loop?: boolean;
  /** Manually controls the active step index, overriding the internal auto-advance timer, when provided. */
  value?: ValueOrState<number>;
  /** Called when the built-in close button is pressed (in addition to setting `loading` to `false`). */
  onClose?: () => void;
}

const DEFAULT_STEPS: MultiStepLoaderStep[] = [
  { text: "Buying a condo" },
  { text: "Travelling in a flight" },
  { text: "Meeting Tyler Durden" },
  { text: "He makes soap" },
  { text: "We goto a bar" },
  { text: "Start a fight" },
  { text: "We like it" },
  { text: "Welcome to Fight Club" },
];

// Approximate row height (icon + gap + text + block padding), in `themeSpacing`
// units — used only to compute the column's scroll-anchoring `translateY`.
const ROW_HEIGHT_UNITS = 11;

/** Outlined circular checkmark, used for every row — "filled" (passed/current)
 * vs "outline" (upcoming) is read from stroke-weight/color/opacity rather than
 * a second hardcoded fill color, so the icon never needs a literal color. */
function stepCheckGlyph(strokeWidth: string): DomphyElement<"span"> {
  return {
    span: [
      {
        svg: [
          { circle: null, cx: "12", cy: "12", r: "9" },
          { polyline: null, points: "8 12 11 15 16 9" },
        ],
        viewBox: "0 0 24 24",
        fill: "none",
        stroke: "currentColor",
        strokeWidth,
        strokeLinecap: "round",
        strokeLinejoin: "round",
        role: "img",
        ariaHidden: "true",
        style: { width: "100%", height: "100%" },
      } as DomphyElement<"svg">,
    ],
    ariaHidden: "true",
    style: { display: "inline-flex", flexShrink: "0", width: themeSpacing(6), height: themeSpacing(6) },
  };
}

/** Small square close ("X") button pinned to the overlay's top-right corner. */
function closeButton(onClose: () => void): DomphyElement<"button"> {
  return {
    button: [
      {
        svg: [
          { line: null, x1: "5", y1: "5", x2: "15", y2: "15" },
          { line: null, x1: "15", y1: "5", x2: "5", y2: "15" },
        ],
        viewBox: "0 0 20 20",
        fill: "none",
        stroke: "currentColor",
        strokeWidth: "2",
        strokeLinecap: "round",
        role: "img",
        ariaHidden: "true",
        style: { width: "100%", height: "100%" },
      } as DomphyElement<"svg">,
    ],
    type: "button",
    ariaLabel: "Close",
    style: {
      position: "absolute",
      top: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
      right: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
      display: "inline-flex",
      alignItems: "center",
      justifyContent: "center",
      width: themeSpacing(8),
      height: themeSpacing(8),
      appearance: "none",
      cursor: "pointer",
      backgroundColor: "transparent",
      borderRadius: themeSpacing(1.5),
      border: (listener: Listener) => `1px solid ${themeColor(listener, "shift-6")}`,
      color: (listener: Listener) => themeColor(listener, "shift-14"),
    } as StyleObject,
    onClick: onClose,
  };
}

/** One step row: icon + label, both reactively styled from this row's distance
 * to the currently active index. */
function stepRow(step: MultiStepLoaderStep, index: number, activeIndex: State<number>): DomphyElement<"div"> {
  const relative = (listener: Listener): number => index - activeIndex.get(listener);

  const opacity = (listener: Listener): number => {
    const distance = relative(listener);
    if (distance <= 0) return 1;
    return Math.max(0, 1 - distance * 0.3);
  };

  const label = (listener: Listener): (string | DomphyElement)[] =>
    relative(listener) === 0
      ? [{ strong: step.text, $: [strong({ color: "neutral" })] } as DomphyElement<"strong">]
      : [step.text];

  const textColor = (listener: Listener): string => {
    const distance = relative(listener);
    if (distance < 0) return themeColor(listener, "shift-6"); // passed: muted gray
    if (distance === 0) return themeColor(listener, "shift-15"); // current: near-black
    return themeColor(listener, "shift-3"); // upcoming: light gray, fades via opacity
  };

  return {
    div: [
      { span: (listener: Listener) => [stepCheckGlyph(relative(listener) === 0 ? "2.5" : "1.5")], style: { display: "inline-flex" } },
      { span: label, style: { display: "inline-flex" } },
    ],
    _key: `multi-step-loader-row-${index}`,
    style: {
      display: "flex",
      alignItems: "center",
      gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 2),
      paddingBlock: (listener: Listener) => themeSpacing(themeDensity(listener) * 0.75),
      fontSize: (listener: Listener) => themeSize(listener, "inherit"),
      color: textColor,
      opacity,
      transition: "opacity 300ms ease, color 300ms ease",
    } as StyleObject,
  };
}

/**
 * A full-screen frosted overlay that walks through `loadingStates` one at a
 * time, auto-advancing on a timer (or under manual `value` control) and
 * showing a dismiss button in the corner. Mounted whenever `loading` is
 * truthy. Call with no arguments for a working demo (mount it and flip
 * `loading` to `true` to see it advance through 8 placeholder steps).
 */
function multiStepLoader(props: MultiStepLoaderProps = {}): DomphyElement<"div"> {
  const steps = props.loadingStates && props.loadingStates.length > 0 ? props.loadingStates : DEFAULT_STEPS;
  const duration = props.duration ?? 2000;
  const loop = props.loop ?? true;
  const loadingState = toState(props.loading ?? false, "loading");
  const manuallyControlled = props.value !== undefined;
  const activeIndex = manuallyControlled ? toState(props.value as ValueOrState<number>, "activeIndex") : toState(0, "activeIndex");

  const close = () => {
    loadingState.set(false);
    props.onClose?.();
  };

  return {
    div: [
      closeButton(close),
      {
        div: [
          {
            div: steps.map((step, index) => stepRow(step, index, activeIndex)),
            style: {
              display: "flex",
              flexDirection: "column",
              transform: (listener: Listener) =>
                `translateY(calc(${-activeIndex.get(listener)} * ${themeSpacing(ROW_HEIGHT_UNITS)}))`,
              transition: "transform 500ms cubic-bezier(0.16, 1, 0.3, 1)",
            } as StyleObject,
          },
        ],
        style: {
          position: "relative",
          overflow: "hidden",
          maxHeight: themeSpacing(ROW_HEIGHT_UNITS * 5),
          maskImage: "linear-gradient(to bottom, transparent, black 15%, black 85%, transparent)",
          WebkitMaskImage: "linear-gradient(to bottom, transparent, black 15%, black 85%, transparent)",
        },
      },
    ],
    role: "status",
    ariaLabel: "Loading",
    dataTone: "shift-2",
    style: {
      position: "fixed",
      inset: 0,
      zIndex: 1000,
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      backdropFilter: "blur(6px)",
      backgroundColor: (listener: Listener) => `color-mix(in srgb, ${themeColor(listener, "inherit")} 65%, transparent)`,
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      opacity: (listener: Listener) => (loadingState.get(listener) ? 1 : 0),
      visibility: (listener: Listener) => (loadingState.get(listener) ? "visible" : "hidden"),
      pointerEvents: (listener: Listener) => (loadingState.get(listener) ? "auto" : "none"),
      transition: "opacity 300ms ease, visibility 300ms ease",
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      if (manuallyControlled) return;
      let timer: ReturnType<typeof setInterval> | null = null;

      const start = () => {
        if (timer) return;
        activeIndex.set(0);
        timer = setInterval(() => {
          const current = activeIndex.get();
          if (current >= steps.length - 1) {
            if (loop) {
              activeIndex.set(0);
            } else if (timer) {
              clearInterval(timer);
              timer = null;
            }
          } else {
            activeIndex.set(current + 1);
          }
        }, duration);
      };
      const stop = () => {
        if (timer) {
          clearInterval(timer);
          timer = null;
        }
      };

      const disposeWatch = watch(loadingState, (isLoading) => (isLoading ? start() : stop()), { immediate: true });
      node.addHook("Remove", () => {
        stop();
        disposeWatch();
      });
    },
  };
}

export { multiStepLoader };

← Back to Aceternity UI catalog