Domphy

videoText

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

Implementation notes

Full SVG-mask-on-video technique implemented: a hidden <svg><mask> holds one <text> glyph (fill via a raw SVG attribute, not style, matching this package's existing chart-label convention), and the <video> (or fallback) is masked via CSS mask-image: url(#id) (+ -webkit- prefix) with maskContentUnits=userSpaceOnUse so glyph centering is responsive with zero JS measurement. _onMount calls .play() with a catch-and-fail-open guard for autoplay-restriction browsers, and imperatively sets the .muted IDL property (not just the content attribute) since some browsers only honor the live property for autoplay. An optional IntersectionObserver pauses/resumes the video when scrolled offscreen (fails open when IntersectionObserver is unavailable). Two honest gaps: (1) no video asset ships with this package, so the zero-argument demo substitutes a looping animated theme-gradient panel behind the same mask rather than a real video — passing videoSrc activates the real masked <video> path (same tradeoff heroVideoDialog already takes with its 'about' default in this package); (2) CSS mask-image referencing an SVG mask fragment on a plain HTML element (not an SVG element) has solid but not universal cross-browser support (full in Chromium/Firefox, prefixed support in modern Safari) — older WebKit may not render the mask.

Status: ported · Reference: Magic UI original

// magicui "Video Text" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). Large
// display text whose glyphs are windows onto a looping, muted background
// video — implemented with the standard SVG-mask-on-video technique: a
// hidden <svg><mask> holds one large <text> (fill: white, everything else
// left unpainted/transparent), and the <video> references that mask via CSS
// `mask-image: url(#id)` (unprefixed + `-webkit-` for Safari). The mask's
// `maskContentUnits="userSpaceOnUse"` lets the <text>'s percentage x/y
// coordinates resolve against the *masked element's own box* (the video),
// so the glyph stays centered and the effect is responsive without any JS
// measurement — the browser's video decoder alone drives the motion, no
// per-frame JS work.
//
// No default video asset ships with this package (see the `videoSrc` prop
// doc below) — the zero-argument demo falls back to a looping animated
// theme-gradient panel behind the same text mask, so `videoText()` still
// renders a fully working, continuously animated demo with zero arguments;
// passing a real `videoSrc` swaps in the actual masked <video>.

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

export interface VideoTextProps {
  /** Text rendered as the video mask's glyph shapes. Defaults to `"OCEAN"`. */
  text?: string;
  /** Video source URL loaded into the masked `<video>`. When omitted, a looping
   * animated theme-gradient panel fills the mask instead — no video asset ships
   * with this package. */
  videoSrc?: string;
  /** Autoplays the video once mounted. Defaults to `true`. */
  autoPlay?: boolean;
  /** Loops the video indefinitely. Defaults to `true`. */
  loop?: boolean;
  /** Mutes the video — required by browsers for autoplay to succeed. Defaults to `true`. */
  muted?: boolean;
  /** `<video>` `preload` strategy. Defaults to `"auto"`. */
  preload?: "auto" | "metadata" | "none";
  /** Glyph font-size, any CSS length. Defaults to a fluid value that scales with viewport width. */
  fontSize?: string;
  /** Glyph font-weight. Defaults to `"800"` (heavy, so each letter reads as a wide video window). */
  fontWeight?: string | number;
  /** Glyph font-family stack. Defaults to a bold generic sans stack. */
  fontFamily?: string;
  /** Container aspect ratio, CSS `aspect-ratio` syntax. Defaults to `"3 / 1"`. */
  aspectRatio?: string;
  /** Theme color family for the fallback gradient panel (used only when `videoSrc` is omitted). Defaults to `"primary"`. */
  fallbackColor?: ThemeColor;
  /** Pauses the video while the container is scrolled out of view, resuming when it re-enters — a small performance courtesy. Defaults to `true`. */
  pauseWhenOffscreen?: boolean;
  /** Passthrough style merged onto the outer container. */
  style?: StyleObject;
}

let videoTextInstanceCounter = 0;

/**
 * Large display text whose glyphs are windows onto a looping, muted
 * background video (an SVG-mask-on-video technique). Call with no arguments
 * for a working demo — an animated gradient fills the letters since no
 * `videoSrc` is bundled with this package.
 */
function videoText(props: VideoTextProps = {}): DomphyElement<"div"> {
  const text = props.text ?? "OCEAN";
  const videoSrc = props.videoSrc;
  const autoPlay = props.autoPlay ?? true;
  const loop = props.loop ?? true;
  const muted = props.muted ?? true;
  const preload = props.preload ?? "auto";
  const fontSize = props.fontSize ?? themeFluidSpacing(48, 220);
  const fontWeight = String(props.fontWeight ?? "800");
  const fontFamily =
    props.fontFamily ?? "ui-sans-serif, system-ui, 'Segoe UI', sans-serif";
  const aspectRatio = props.aspectRatio ?? "3 / 1";
  const fallbackColor = props.fallbackColor ?? "primary";
  const pauseWhenOffscreen = props.pauseWhenOffscreen ?? true;

  const instanceId = ++videoTextInstanceCounter;
  const maskId = `domphy-video-text-mask-${instanceId}`;

  // Hidden defs-only host — zero size, purely a place to declare the <mask>.
  // A `<mask>` element's own children never render on their own; they only
  // paint when referenced via `mask-image`/`mask` on another element.
  const maskDefs: DomphyElement<"svg"> = {
    svg: [
      {
        mask: [
          {
            text,
            x: "50%",
            y: "50%",
            textAnchor: "middle",
            dominantBaseline: "central",
            fill: "white",
            fontSize,
            fontWeight,
            fontFamily,
          } as DomphyElement<"text">,
        ],
        id: maskId,
        maskContentUnits: "userSpaceOnUse",
      } as DomphyElement<"mask">,
    ],
    width: "0",
    height: "0",
    xmlns: "http://www.w3.org/2000/svg",
    ariaHidden: "true",
    style: { position: "absolute" },
  };

  const maskedFillStyle: StyleObject = {
    position: "absolute",
    inset: 0,
    width: "100%",
    height: "100%",
    maskImage: `url(#${maskId})`,
    WebkitMaskImage: `url(#${maskId})`,
    maskRepeat: "no-repeat",
    WebkitMaskRepeat: "no-repeat",
    maskSize: "100% 100%",
    WebkitMaskSize: "100% 100%",
  };

  const gradientKeyframes = {
    from: { backgroundPosition: "0% 50%" },
    to: { backgroundPosition: "300% 50%" },
  };
  const gradientAnimationName = `video-text-fallback-flow-${hashString(
    JSON.stringify({ instanceId, gradientKeyframes }),
  )}`;

  const fillLayer: DomphyElement = videoSrc
    ? ({
        video: [],
        src: videoSrc,
        autoPlay,
        loop,
        muted,
        playsInline: true,
        preload,
        ariaHidden: "true",
        style: { ...maskedFillStyle, objectFit: "cover" } as StyleObject,
        _onMount: (node: ElementNode) => {
          const videoElement = node.domElement as HTMLVideoElement;
          // Setting the `muted` IDL property imperatively (not just the
          // content attribute) is required by some browsers' autoplay
          // policies — the content attribute alone only seeds
          // `defaultMuted`, not the live playback state.
          videoElement.muted = muted;
          if (!autoPlay) return;
          const playResult = videoElement.play();
          // Autoplay can be rejected by the browser (e.g. no user gesture yet
          // on a strict mobile policy) — fail open, the frame just stays on
          // the video's poster/first frame rather than throwing.
          if (playResult && typeof playResult.catch === "function") {
            playResult.catch(() => {});
          }
        },
      } as DomphyElement<"video">)
    : ({
        div: null,
        ariaHidden: "true",
        // Decorative gradient stand-in for the (unbundled) video — no text of
        // its own, exempt from the missing-color contract.
        _doctorDisable: "missing-color",
        style: {
          ...maskedFillStyle,
          backgroundImage: (listener: Listener) =>
            `linear-gradient(90deg, ${themeColor(listener, "shift-8", fallbackColor)}, ${themeColor(listener, "shift-2", fallbackColor)}, ${themeColor(listener, "shift-11", fallbackColor)}, ${themeColor(listener, "shift-8", fallbackColor)})`,
          backgroundSize: "300% 100%",
          animation: `${gradientAnimationName} 6s linear infinite`,
          [`@keyframes ${gradientAnimationName}`]: gradientKeyframes,
        } as StyleObject,
      } as DomphyElement<"div">);

  const offscreenPauseHook = (node: ElementNode) => {
    if (typeof IntersectionObserver !== "function") return;
    const container = node.domElement as HTMLElement;
    const videoElement = container.querySelector(
      "video",
    ) as HTMLVideoElement | null;
    if (!videoElement) return;
    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) {
            if (!autoPlay) continue;
            const playResult = videoElement.play();
            if (playResult && typeof playResult.catch === "function") {
              playResult.catch(() => {});
            }
          } else {
            videoElement.pause();
          }
        }
      },
      { threshold: 0.1 },
    );
    observer.observe(container);
    node.addHook("Remove", () => observer.disconnect());
  };

  const outer: DomphyElement<"div"> = {
    div: [fillLayer, maskDefs],
    role: "img",
    ariaLabel: text,
    dataTone: "shift-16",
    // `_onMount` must be a function or entirely absent — a `key: undefined`
    // entry is rejected by the framework's hook validation, so this is only
    // included when the pause-on-offscreen behavior actually applies.
    ...(pauseWhenOffscreen && videoSrc ? { _onMount: offscreenPauseHook } : {}),
    style: {
      position: "relative",
      overflow: "hidden",
      width: "100%",
      aspectRatio,
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      ...(props.style ?? {}),
    },
  };

  return outer;
}

export { videoText };

← Back to Magic UI catalog