Skip to content
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.

Props

PropTypeDescription
textstringText rendered as the video mask's glyph shapes. Defaults to "OCEAN".
videoSrcstringVideo 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.
autoPlaybooleanAutoplays the video once mounted. Defaults to true.
loopbooleanLoops the video indefinitely. Defaults to true.
mutedbooleanMutes the video — required by browsers for autoplay to succeed. Defaults to true.
preload"auto" | "metadata" | "none"<video> preload strategy. Defaults to "auto".
fontSizestringGlyph font-size, any CSS length. Defaults to "20vw" (viewport-relative, matching upstream).
fontWeightstring | numberGlyph font-weight. Defaults to "bold" (matching upstream).
fontFamilystringGlyph font-family stack. Defaults to the generic "sans-serif" (matching upstream).
aspectRatiostringFallback aspect ratio for standalone use, CSS aspect-ratio syntax. The container is height:100% like upstream's size-full, so a parent with a definite height overrides this; the ratio only engages when the parent height is indefinite. Defaults to "3 / 1".
fallbackColorThemeColorTheme color family for the fallback gradient panel (used only when videoSrc is omitted). Defaults to "primary".
styleStyleObjectPassthrough style merged onto the outer container.
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 } 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 `"20vw"` (viewport-relative, matching upstream). */
  fontSize?: string;
  /** Glyph font-weight. Defaults to `"bold"` (matching upstream). */
  fontWeight?: string | number;
  /** Glyph font-family stack. Defaults to the generic `"sans-serif"` (matching upstream). */
  fontFamily?: string;
  /** Fallback aspect ratio for standalone use, CSS `aspect-ratio` syntax. The container is
   * `height:100%` like upstream's `size-full`, so a parent with a definite height overrides
   * this; the ratio only engages when the parent height is indefinite. 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;
  /** 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 ?? "20vw";
  const fontWeight = String(props.fontWeight ?? "bold");
  const fontFamily = props.fontFamily ?? "sans-serif";
  const aspectRatio = props.aspectRatio ?? "3 / 1";
  const fallbackColor = props.fallbackColor ?? "primary";

  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: "middle",
            fill: "white",
            fontSize,
            fontWeight,
            fontFamily,
          } as DomphyElement<"text">,
        ],
        id: maskId,
        maskContentUnits: "userSpaceOnUse",
      } as DomphyElement<"mask">,
    ],
    // A `<mask>` element's children never paint on their own — but the
    // `<text>`'s percentage `x`/`y` (userSpaceOnUse) resolve against *this*
    // SVG's own viewport, not the masked video's box. Sizing it `0x0` (as if
    // it were purely a hidden defs host) collapsed that viewport to nothing,
    // so `50%`/`50%` resolved to the origin — the glyphs rendered
    // top-left-anchored and mostly clipped instead of centered. Stretching
    // this SVG to match the container (still invisible; mask content only
    // ever paints through a `mask-image` reference) gives the percentages a
    // real, non-zero coordinate space to resolve against.
    width: "100%",
    height: "100%",
    xmlns: "http://www.w3.org/2000/svg",
    ariaHidden: "true",
    style: {
      position: "absolute",
      inset: 0,
      pointerEvents: "none",
    } as StyleObject,
  };

  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 outer: DomphyElement<"div"> = {
    div: [fillLayer, maskDefs],
    role: "img",
    ariaLabel: text,
    dataTone: "shift-0",
    style: {
      position: "relative",
      overflow: "hidden",
      width: "100%",
      // Upstream's container is `size-full` (width:100%; height:100%), filling a
      // sized parent with no aspect-ratio of its own. `height:100%` reproduces
      // that; `aspectRatio` only engages as a fallback when the parent height is
      // indefinite (the standalone zero-arg demo) — there `height:100%` resolves
      // to `auto`, so the ratio derives height from the definite width.
      height: "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