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
| Prop | Type | Description |
|---|---|---|
text | string | Text rendered as the video mask's glyph shapes. Defaults to "OCEAN". |
videoSrc | 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. |
autoPlay | boolean | Autoplays the video once mounted. Defaults to true. |
loop | boolean | Loops the video indefinitely. Defaults to true. |
muted | boolean | Mutes the video — required by browsers for autoplay to succeed. Defaults to true. |
preload | "auto" | "metadata" | "none" | <video> preload strategy. Defaults to "auto". |
fontSize | string | Glyph font-size, any CSS length. Defaults to "20vw" (viewport-relative, matching upstream). |
fontWeight | string | number | Glyph font-weight. Defaults to "bold" (matching upstream). |
fontFamily | string | Glyph font-family stack. Defaults to the generic "sans-serif" (matching upstream). |
aspectRatio | 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". |
fallbackColor | ThemeColor | Theme color family for the fallback gradient panel (used only when videoSrc is omitted). Defaults to "primary". |
style | StyleObject | Passthrough 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 };