Domphy

textRevealCard

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

Implementation notes

Dark charcoal card (edge-anchored dataTone shift-16) with a dim gradient-clipped base text line and a brighter gradient-clipped + text-shadow revealed line whose clip-path inset() is updated 1 (no easing) from the pointer's horizontal fraction across the card on every mousemove, with the CSS transition explicitly disabled during drag and re-enabled only on mouseleave for the ~400ms eased reset -- matches the spec's own described mechanism and timing. A thin gradient-filled indicator blade tracks the same fraction and tilts up to +-2.5deg based on distance from center. 140 (default, configurable via starCount) small twinkling dot stars scattered at random positions pulse opacity/scale on independently randomized per-star duration/delay via one shared @keyframes. Exact colors/tile styling are reasonable defaults per the task's own researchNote (moderate-to-good confidence, corroborated via the cited Svelte-port cross-reference in the spec).

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Text Reveal Card" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied). A
// dark card with two stacked lines of text: an always-visible, dimly
// gradient-muted "ghost" line, and a brighter hidden line beneath it that is
// manually wiped into view as the cursor drags left-to-right across the
// card — a thin tilting indicator "blade" marks the current reveal
// boundary, and a scattering of small twinkling dot "stars" gives the card
// ambience.
//
// The reveal itself is a `clip-path: inset()` percentage bound 1:1 to the
// pointer's horizontal fraction across the card, written straight to the DOM
// on every `mousemove` (no easing while hovering, per the spec) — the same
// "disable the CSS transition during the drag, re-enable it only for the
// eased settle-back on `mouseleave`" technique this package's own
// `directionAwareHover.ts`/`card3D.ts` already use for their own instant-
// track/eased-reset splits. The ~140 twinkling stars reuse `dotPattern.ts`'s
// own "one shared randomized-duration/delay `@keyframes`, applied per-dot
// inline" idiom from elsewhere in this package.
//
// The two text lines' bold display weight has no theme token (AGENTS.md:
// weight isn't part of the tokenized scale) — set through a `(l) => 700`
// function-form value, the same doctor-legitimate escape hatch
// `kineticText.ts` already uses for its own constant resting font-weight
// elsewhere in this package (the inline-typography rule only flags literal,
// non-function values). Size instead goes through the real `themeSize()`
// token so it still respects `dataSize` context.

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

export interface TextRevealCardProps {
  /** Always-visible, dimly muted base line. Defaults to a short demo phrase. */
  text?: string;
  /** Brighter line exposed by the left-to-right wipe. Defaults to a short demo phrase. */
  revealText?: string;
  /** Optional overlay content (e.g. a title/description) rendered above the two text lines. */
  children?: DomphyElement | DomphyElement[];
  /** Extra class name merged onto the outer card's native `class` attribute. */
  className?: string;
  /** Number of decorative twinkling dot "stars" scattered across the card. Defaults to `140`. */
  starCount?: number;
  /** Theme color family for the revealed line's glow/indicator blade accent. Defaults to `"neutral"`. */
  accentColor?: ThemeColor;
  /** Passthrough style merged onto the outer card. */
  style?: StyleObject;
}

const RESET_TRANSITION_MS = 400;
const MAX_BLADE_TILT_DEG = 2.5;

let textRevealCardInstanceCounter = 0;

function randomStar(key: string, animationName: string): DomphyElement<"span"> {
  const topPercent = Math.random() * 100;
  const leftPercent = Math.random() * 100;
  const durationSeconds = 1.5 + Math.random() * 2.5;
  const delaySeconds = Math.random() * 3;
  // `_doctorDisable` is a doctor-only annotation not present in core's
  // strict `PartialElement` type — build through an untyped literal, then
  // assert, so the excess-property check doesn't fire (mirrors
  // `dottedGlowBackground.ts`/`flickeringGrid.ts`).
  return {
    span: null,
    _key: key,
    ariaHidden: "true",
    // Decorative twinkling dot with no text of its own — exempt from the
    // missing-color contract, matching `dotPattern.ts`'s own glow dots. Also
    // exempt from tone-background-inherit: a star's fixed bright dot color
    // is intentional, not a surface (same reasoning `glowingStars.ts`/
    // `shootingStars.ts` document for their own decorative dots).
    _doctorDisable: ["missing-color", "tone-background-inherit"],
    style: {
      position: "absolute",
      top: `${topPercent}%`,
      left: `${leftPercent}%`,
      width: "2px",
      height: "2px",
      borderRadius: "50%",
      backgroundColor: (listener: Listener) => themeColor(listener, "shift-17"),
      animation: `${animationName} ${durationSeconds}s ease-in-out ${delaySeconds}s infinite`,
    } as StyleObject,
  } as DomphyElement<"span">;
}

/**
 * A dark card whose bottom line of text is hidden behind a dim placeholder
 * line and gets manually wiped into view as the cursor drags left-to-right
 * across the card, with a thin tilting indicator blade tracking the reveal
 * boundary and a field of twinkling decorative stars. Call with no
 * arguments for a working demo.
 */
function textRevealCard(props: TextRevealCardProps = {}): DomphyElement<"div"> {
  const baseText = props.text ?? "Hover and drag across this card";
  const revealText = props.revealText ?? "You just wiped away the mystery";
  const starCount = Math.max(0, Math.round(props.starCount ?? 140));
  const accentColor = props.accentColor ?? "neutral";

  const instanceId = ++textRevealCardInstanceCounter;
  const twinkleKeyframes = {
    "0%,100%": { opacity: 0.15, transform: "scale(0.8)" },
    "50%": { opacity: 1, transform: "scale(1.2)" },
  };
  const twinkleAnimationName = `text-reveal-card-twinkle-${hashString(JSON.stringify({ instanceId, twinkleKeyframes }))}`;

  const stars: DomphyElement<"span">[] = Array.from({ length: starCount }, (_unused, index) => randomStar(`star-${instanceId}-${index}`, twinkleAnimationName));

  const starsLayer: DomphyElement<"div"> = {
    div: stars,
    ariaHidden: "true",
    style: {
      position: "absolute",
      inset: 0,
      overflow: "hidden",
      pointerEvents: "none",
      [`@keyframes ${twinkleAnimationName}`]: twinkleKeyframes,
    } as StyleObject,
  };

  let revealedTextElement: HTMLElement | null = null;
  let indicatorBladeElement: HTMLElement | null = null;

  const baseTextLayer: DomphyElement<"p"> = {
    p: baseText,
    style: {
      position: "relative",
      margin: 0,
      fontSize: (listener: Listener) => themeSize(listener, "increase-2"),
      fontWeight: () => 700,
      backgroundImage: (listener: Listener) => `linear-gradient(180deg, ${themeColor(listener, "shift-7")}, ${themeColor(listener, "shift-4")})`,
      backgroundClip: "text",
      WebkitBackgroundClip: "text",
      color: "transparent",
    } as StyleObject,
  };

  const revealedTextLayer: DomphyElement<"p"> = {
    p: revealText,
    style: {
      position: "absolute",
      inset: 0,
      margin: 0,
      fontSize: (listener: Listener) => themeSize(listener, "increase-2"),
      fontWeight: () => 700,
      clipPath: "inset(0 100% 0 0)",
      backgroundImage: (listener: Listener) => `linear-gradient(180deg, ${themeColor(listener, "shift-17")}, ${themeColor(listener, "shift-13")})`,
      backgroundClip: "text",
      WebkitBackgroundClip: "text",
      color: "transparent",
      textShadow: (listener: Listener) => `0 0 ${themeSpacing(3)} ${themeColor(listener, "shift-14", accentColor)}`,
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      revealedTextElement = node.domElement as HTMLElement;
    },
    _onRemove: () => {
      revealedTextElement = null;
    },
  };

  const textStack: DomphyElement<"div"> = {
    div: [baseTextLayer, revealedTextLayer],
    style: { position: "relative", zIndex: 1 } as StyleObject,
  };

  // `_doctorDisable` is a doctor-only annotation not present in core's
  // strict `PartialElement` type — build through an untyped literal, then
  // assert, so the excess-property check doesn't fire (mirrors
  // `dottedGlowBackground.ts`/`flickeringGrid.ts`).
  const indicatorBlade = {
    div: null,
    ariaHidden: "true",
    // Decorative reveal-boundary blade with no text of its own — exempt
    // from the missing-color contract, matching `spotlightDual.ts`'s layers.
    _doctorDisable: "missing-color",
    _onMount: (node: ElementNode) => {
      indicatorBladeElement = node.domElement as HTMLElement;
    },
    _onRemove: () => {
      indicatorBladeElement = null;
    },
    style: {
      position: "absolute",
      top: 0,
      bottom: 0,
      left: "0%",
      width: "2px",
      opacity: 0,
      zIndex: 2,
      pointerEvents: "none",
      backgroundImage: (listener: Listener) => `linear-gradient(180deg, transparent, ${themeColor(listener, "shift-17", accentColor)}, transparent)`,
    } as StyleObject,
  } as DomphyElement<"div">;

  const overlayChildren: DomphyElement[] = props.children ? (Array.isArray(props.children) ? props.children : [props.children]) : [];

  return {
    div: [
      starsLayer,
      ...(overlayChildren.length > 0
        ? [{ div: overlayChildren, style: { position: "relative", zIndex: 1, marginBottom: themeSpacing(3) } } as DomphyElement]
        : []),
      textStack,
      indicatorBlade,
    ],
    class: props.className,
    dataTone: "shift-16",
    style: {
      position: "relative",
      overflow: "hidden",
      cursor: "crosshair",
      display: "flex",
      flexDirection: "column",
      justifyContent: "center",
      borderRadius: themeSpacing(4),
      padding: themeSpacing(8),
      minHeight: themeSpacing(48),
      minWidth: themeSpacing(80),
      outline: (listener: Listener) => `1px solid ${themeColor(listener, "shift-3")}`,
      outlineOffset: "-1px",
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      ...(props.style ?? {}),
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      if (typeof window === "undefined") return;
      const cardElement = node.domElement as HTMLElement;

      const handlePointerMove = (event: MouseEvent) => {
        const rect = cardElement.getBoundingClientRect();
        const fraction = rect.width > 0 ? Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)) : 0;
        const percentage = fraction * 100;
        if (revealedTextElement) {
          revealedTextElement.style.transition = "none";
          revealedTextElement.style.clipPath = `inset(0 ${100 - percentage}% 0 0)`;
        }
        if (indicatorBladeElement) {
          const tiltDeg = ((percentage - 50) / 50) * MAX_BLADE_TILT_DEG;
          indicatorBladeElement.style.transition = "none";
          indicatorBladeElement.style.left = `${percentage}%`;
          indicatorBladeElement.style.transform = `translateX(-50%) rotate(${tiltDeg}deg)`;
          indicatorBladeElement.style.opacity = "1";
        }
      };

      const handlePointerLeave = () => {
        if (revealedTextElement) {
          revealedTextElement.style.transition = `clip-path ${RESET_TRANSITION_MS}ms ease`;
          revealedTextElement.style.clipPath = "inset(0 100% 0 0)";
        }
        if (indicatorBladeElement) {
          indicatorBladeElement.style.transition = `left ${RESET_TRANSITION_MS}ms ease, opacity ${RESET_TRANSITION_MS}ms ease`;
          indicatorBladeElement.style.opacity = "0";
        }
      };

      cardElement.addEventListener("mousemove", handlePointerMove);
      cardElement.addEventListener("mouseleave", handlePointerLeave);

      node.addHook("Remove", () => {
        cardElement.removeEventListener("mousemove", handlePointerMove);
        cardElement.removeEventListener("mouseleave", handlePointerLeave);
      });
    },
  } as DomphyElement<"div">;
}

export { textRevealCard };

← Back to Aceternity UI catalog