Domphy

diaTextReveal

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

Implementation notes

Gradient sweep via background-clip over a plain solid base layer, IntersectionObserver view-trigger, setTimeout-sequenced duration/delay/pauseBetween, list-cycling with optional infinite repeat, and reserveWidth (ch-based min-width) are all implemented per spec. Gap: the spec's 'requiring a manual trigger' (autoStart) implies an external imperative trigger API (e.g. a React ref/method), but this package's components are complete factory-function trees with no exposed imperative handle — substituted with a click-to-play interaction on the element itself as the closest reasonable equivalent, documented in the JSDoc and file header. Default colors use 5 theme ColorRole tokens (primary/secondary/info/success/warning) instead of raw hex, per Domphy's no-literal-color constraint.

Status: partial · Reference: Magic UI original

// magicui "Dia Text Reveal" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). Plain
// text that rests in a solid final color, but the first time it scrolls
// into view a multicolor gradient band sweeps across it once — clipped to
// the glyph shapes via `background-clip: text` — like a brief foil-shine
// passing over metallic text, before the layer fades out to reveal the
// plain solid color underneath, permanently. Can optionally cycle through a
// list of strings, sweeping each one in with a pause between, looping.
//
// Two stacked copies of the same text (the well-established "twin-layer"
// technique already used by sibling text blocks in this file, e.g.
// `sparklesText`): a plain solid-color base layer that is always the
// visible resting state, and a gradient-clipped sweep layer stacked exactly
// on top whose opacity and CSS `animation` are both bound to a reactive
// "is sweeping" flag — so the sweep only exists (visually and
// computationally) while actually playing, then disappears to reveal the
// base layer beneath. Trigger timing (view-entry delay, sweep duration,
// inter-item pause, looping) is plain JS `setTimeout` sequencing, not a
// fixed CSS timeline.

import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { hashString, toState } from "@domphy/core";
import { type ThemeColor, themeColor } from "@domphy/theme";

export interface DiaTextRevealProps {
  /** Text to display, or a list of strings to cycle through (one sweep per item). Defaults to a demo phrase. */
  children?: string | string[];
  /** Gradient colors the sweeping band travels through, left to right. Defaults to five vivid theme roles. */
  colors?: ThemeColor[];
  /** Theme color the text rests at once the sweep has passed. Defaults to `"neutral"` (normal foreground text). */
  finalColor?: ThemeColor;
  /** Milliseconds the sweep itself takes to travel across the text. Defaults to `1500`. */
  duration?: number;
  /** Milliseconds to wait (after the trigger fires) before the first sweep starts. Defaults to `0`. */
  delay?: number;
  /** Starts the sweep automatically the first time the element scrolls into view. When `false`, the
   * sweep instead waits for a click (there is no external imperative trigger in this factory-function
   * API, so click is the manual-trigger substitute). Defaults to `true`. */
  autoStart?: boolean;
  /** When multiple `children` items are given, loop back to the first after the last instead of
   * stopping there; when a single string is given, keep re-sweeping it on `duration + pauseBetween`
   * cycles instead of sweeping only once. Defaults to `false`. */
  repeat?: boolean;
  /** Milliseconds paused (settled, solid color) between one item's sweep finishing and the next
   * starting. Defaults to `500`. */
  pauseBetween?: number;
  /** Reserves enough inline width for the longest item so cycling text doesn't shift surrounding
   * layout. Defaults to `false`. */
  reserveWidth?: boolean;
  /** Passthrough style merged onto the outer wrapper. */
  style?: StyleObject;
}

const DEFAULT_COLORS: ThemeColor[] = ["primary", "secondary", "info", "success", "warning"];

let diaTextRevealInstanceCounter = 0;

/**
 * Text that rests in a solid final color but is swept once by a moving
 * multicolor gradient band the first time it scrolls into view — a brief
 * foil-shine "light sweep" — before settling. Optionally cycles through a
 * list of strings, sweeping each in turn. Call with no arguments for a
 * working demo.
 */
function diaTextReveal(props: DiaTextRevealProps = {}): DomphyElement<"span"> {
  const items = (() => {
    const raw = props.children ?? "Reveal Yourself";
    const list = Array.isArray(raw) ? raw : [raw];
    return list.length > 0 ? list : ["Reveal Yourself"];
  })();
  const colors = props.colors ?? DEFAULT_COLORS;
  const finalColor = props.finalColor ?? "neutral";
  const duration = props.duration ?? 1500;
  const delay = props.delay ?? 0;
  const autoStart = props.autoStart ?? true;
  const repeat = props.repeat ?? false;
  const pauseBetween = props.pauseBetween ?? 500;
  const reserveWidth = props.reserveWidth ?? false;

  const instanceId = ++diaTextRevealInstanceCounter;
  const keyframes = { "0%": { backgroundPosition: "0% 0" }, "100%": { backgroundPosition: "300% 0" } };
  const animationName = `dia-text-reveal-sweep-${hashString(JSON.stringify({ instanceId, duration }))}`;

  const currentText = toState(items[0]);
  const isSweeping = toState(false);

  const longestItemLength = items.reduce((max, item) => Math.max(max, item.length), 0);

  const gradientTextLayer: DomphyElement<"span"> = {
    span: (listener: Listener) => currentText.get(listener),
    ariaHidden: "true",
    style: {
      position: "absolute",
      insetBlockStart: 0,
      insetInlineStart: 0,
      whiteSpace: "nowrap",
      opacity: (listener: Listener) => (isSweeping.get(listener) ? 1 : 0),
      backgroundImage: (listener: Listener) =>
        `linear-gradient(90deg, ${colors.map((color) => themeColor(listener, "shift-8", color)).join(", ")})`,
      backgroundSize: "300% 100%",
      backgroundRepeat: "no-repeat",
      backgroundClip: "text",
      WebkitBackgroundClip: "text",
      color: "transparent",
      transition: "opacity 150ms ease",
      animation: (listener: Listener) =>
        isSweeping.get(listener) ? `${animationName} ${duration}ms cubic-bezier(0.16, 1, 0.3, 1) 1 both` : "none",
      [`@keyframes ${animationName}`]: keyframes,
    } as StyleObject,
  };

  const baseTextLayer: DomphyElement<"span"> = {
    span: (listener: Listener) => currentText.get(listener),
    style: {
      position: "relative",
      whiteSpace: "nowrap",
      color: (listener: Listener) => themeColor(listener, "shift-11", finalColor),
    },
  };

  return {
    span: [baseTextLayer, gradientTextLayer],
    style: {
      position: "relative",
      display: "inline-block",
      ...(reserveWidth ? { minWidth: `${longestItemLength}ch` } : {}),
      ...(props.style ?? {}),
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      if (typeof window === "undefined") return;
      const element = node.domElement as HTMLElement;
      // Bare (non `window.`-qualified) timer functions: with both DOM and
      // Node ambient globals in scope, `window.setTimeout`'s return type
      // resolves inconsistently against `ReturnType<typeof window.setTimeout>`
      // depending on call-site vs type-query context — the bare globals
      // resolve to a single consistent type instead (same fix already
      // applied to `sparklesText` elsewhere in this package).
      const pendingTimeouts = new Set<ReturnType<typeof setTimeout>>();
      let itemIndex = 0;
      let hasStarted = false;

      const runSweep = () => {
        isSweeping.set(true);
        const sweepTimeout = setTimeout(() => {
          pendingTimeouts.delete(sweepTimeout);
          isSweeping.set(false);
          const isLastItem = itemIndex === items.length - 1;
          if (isLastItem && !repeat) return;
          const pauseTimeout = setTimeout(() => {
            pendingTimeouts.delete(pauseTimeout);
            itemIndex = (itemIndex + 1) % items.length;
            currentText.set(items[itemIndex]);
            runSweep();
          }, pauseBetween);
          pendingTimeouts.add(pauseTimeout);
        }, duration);
        pendingTimeouts.add(sweepTimeout);
      };

      const beginSequence = () => {
        if (hasStarted) return;
        hasStarted = true;
        const startTimeout = setTimeout(runSweep, delay);
        pendingTimeouts.add(startTimeout);
      };

      let intersectionObserver: IntersectionObserver | null = null;
      let handleClick: (() => void) | null = null;

      if (autoStart) {
        if (typeof IntersectionObserver !== "function") {
          // No IntersectionObserver support (e.g. a non-browser test runtime)
          // — fail open and play immediately rather than never playing.
          beginSequence();
        } else {
          intersectionObserver = new IntersectionObserver(
            (entries) => {
              for (const entry of entries) {
                if (!entry.isIntersecting) continue;
                beginSequence();
                intersectionObserver?.disconnect();
                intersectionObserver = null;
              }
            },
            { threshold: 0.2 },
          );
          intersectionObserver.observe(element);
        }
      } else {
        handleClick = () => beginSequence();
        element.style.cursor = "pointer";
        element.addEventListener("click", handleClick);
      }

      node.addHook("Remove", () => {
        for (const timeout of pendingTimeouts) clearTimeout(timeout);
        pendingTimeouts.clear();
        intersectionObserver?.disconnect();
        if (handleClick) element.removeEventListener("click", handleClick);
      });
    },
  } as DomphyElement<"span">;
}

export { diaTextReveal };

← Back to Magic UI catalog