Text Clip Media
Display type used as a window onto media — a photo, a muted clip or a token gradient fills the glyphs, leans away from the pointer, and degrades to a solid token when the media never arrives.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/text-clip-media.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "TextClipMedia" component (no animation
library; one rAF loop, matchMedia, ResizeObserver and IntersectionObserver).
Contract
- forwardRef element extending Omit<React.HTMLAttributes<HTMLElement>,
"children">; spread the rest onto the root.
- children: string — the display copy. "\n" starts a new line.
- Props: as ("span" | "div" | "p" | "h1".."h6", default "span"),
media ("image" | "video" | "gradient", default "image"),
src, fit ("cover" | "contain", default "cover"),
fallback (token NAME: "foreground" | "primary" | "muted-foreground" |
"chart-1".."chart-5", default "foreground"),
gradient (array of the same token names, default primary/chart-2/chart-4),
animate (default true), parallax (default true), speed (seconds per drift
cycle, default 16).
- fit is ignored by media="gradient"; gradient is ignored by the other kinds;
say so in the JSDoc rather than making the consumer read the source.
Behavior
- Two clipping paths, one public contract:
* image / gradient -> paint the fill as the element's own background and
clip it to the letters (background-clip: text + transparent color). The
copy stays real text, wraps normally (whitespace-pre-line) and keeps the
page font.
* video -> a real <video> in an absolutely positioned layer, masked by an
SVG data-URI stencil built at runtime from the measured line boxes: one
<text> per line at its measured x / baseline, textLength + lengthAdjust=
"spacingAndGlyphs" pinned to its measured width so the stencil stays
aligned even though an SVG used as an image cannot load your web fonts.
Each line is nowrap; if a line escapes the measured box, return no
stencil and stay on the fallback instead of clipping a lie. Root is
inline-block so the overlay has one box to position against.
- Never-invisible rule (the whole point): render solid token-coloured text
first — that is also what the server sends — and upgrade to the clipped
fill only after (a) CSS.supports proves the technique, read through
useSyncExternalStore with a false server snapshot, and (b) the media
actually loaded. Images are probed off-DOM with new Image() (its
naturalWidth/Height also feed the cover maths); the video reports via
onLoadedMetadata / onLoadedData / onError — accept the metadata signal too,
because a preload="metadata" engine is allowed to stop at HAVE_METADATA and
never fire loadeddata. Key the verdict by the src it was reached for, so
changing src invalidates it by derivation instead of by a reset effect.
Swap in one commit, never a cross-fade: the same render that turns the real
copy transparent turns the mask layer opaque, or the letters would spend
the fade transparent over a fill that is not there yet.
Any failure — no src, 404, unsupported engine, forced colours, an
overflowing line — lands back on the solid token, and in the forced-colours
case the media layer is not even mounted, so nothing paints over the
system palette. Also paint that token as background-color UNDER
the media, so a contain fit or a transparent PNG reads as solid type
instead of a hole.
- Motion: one rAF loop writes two custom properties (--x / --y) on the root;
the background-position calc() and the video transform both read them, so
the loop never branches on media kind. Ambient drift is a slow two-axis
sine (speed sets the period); a passive pointermove on the root records
coordinates only and the loop does at most one getBoundingClientRect per
frame, easing the fill away from the cursor and back on leave. Pan
amplitude is clamped to the slack that exists: images are drawn ~16% larger
than cover, the gradient tile is 220% of the box, the video layer zooms
~14% while panning — and fit="contain" pans by nothing at all, because
there is no slack.
- Cleanup and gating: cancel the rAF, disconnect both observers, remove the
pointer/visibilitychange listeners and delete the custom properties on
unmount. Suspend the loop when the tab is hidden and when an
IntersectionObserver says the headline is off screen; park it entirely once
a pointer-only instance has settled, and restart on the next pointer event.
Pause the video off screen too. Hold the root and the video in state via
callback refs (not useRef) and depend on those nodes: `as` swaps the root
element and the video only mounts once support is known, so an effect that
captured a ref would keep observing a detached node — and the play() that
makes a metadata-only engine fetch frames would never fire.
- prefers-reduced-motion, (pointer: coarse) and (forced-colors: active) are
read with matchMedia through useSyncExternalStore, so a mid-session change
is respected and all three are false during SSR. Forced colours: the clip
is switched off and the headline is plain system-coloured text — a photo
inside the glyphs would paint straight over the contrast guarantee the
mode exists to make. Reduced motion: no loop, no zoom, and the video holds
its first decoded frame — the fill is still fully there, only the movement
is gone. Coarse pointer: drift only, and because the pointer listener is
passive it can never swallow a touch scroll.
Rendering & styling
- Semantic tokens only. Colours come from a name -> var(--token) lookup
table (foreground / primary / muted-foreground / chart-1..5); a consumer
cannot pass a hex. The one literal in the file is fill="white" inside the
SVG stencil, which is coverage (opaque in both alpha and luminance
masking), not a design colour.
- Merge className with cn() and spread the consumer's style object last.
- Accessibility: the copy is always real text in the flow — transparent, not
hidden — so it is announced, selected and copied normally; the media layer
is aria-hidden and pointer-events-none. Nothing here is interactive, so
there is no hover state to mirror on focus: the pointer pan is ambience on
top of a drift everyone gets. Expose data-state="clipped" | "fallback" for
tests. Do not announce the swap — the words never changed, only their fill.
- SSR safety: no window/document during render; every browser read lives in
an effect or a useSyncExternalStore snapshot.
Customization levers
- Fill kind: media picks the whole aesthetic — a photo for warmth, a muted
clip for a launch page, gradient for a zero-asset build.
- Palette: fallback and gradient are token names, so retheming (and dark
mode) retints every headline for free; fallback doubles as the colour under
the media, so pick one that reads on its own.
- Motion budget: speed (12-20s reads as ambient, under 6s reads as an
effect), animate for the drift, parallax for the cursor. Turn both off for
a print-like still.
- Framing: fit="cover" fills and pans, fit="contain" shows the whole frame
and holds still; the pan amplitude, overscan and video zoom are three
constants at the top of the file.
- Typography: the component styles nothing — pass the size, weight, tracking
and leading via className. Heavy, tight, uppercase display faces show the
most media per glyph; light or small text reads as noise.
- Tag: as="h1"/"h2" keeps the document outline honest; as="span" clips one
phrase inside a longer line.Concepts
- Enhancement, never a gamble — the readable solid-token headline is the base state and the clipped fill is layered on top of it only after support and the media both check out, and the two swap in the same commit rather than cross-fading; there is no frame in which the copy is transparent with nothing behind it. Forced-colours mode stays on that base state on purpose: the media layer is never mounted, so nothing paints over the contrast the mode guarantees.
- Load probe before clip — the image is fetched off-DOM first, which turns "the CSS background silently 404ed" (a hole where a word should be) into a decision the component makes, and hands back the natural size the cover maths needs.
- Two paths, one contract —
background-clip: textkeeps real wrapping text for images and gradients; video cannot be a background, so it gets an SVG stencil generated from the measured line boxes. The props, the fallback and the pan behave identically either way. - Stencil pinned by measurement — an SVG used as a mask image renders with locally available fonts only, so each line's
textLengthis pinned to the width the browser actually laid out; the mask tracks the real type instead of drifting away from it. - Amplitude from real slack — the pan can only move as far as the fill overhangs the box, so images are drawn slightly larger than
coverand the gradient tile is oversized on purpose;containhas no overhang and therefore honestly does not move. - Pointer as ambience, not as interaction — the cursor biases a drift that runs anyway, the listener is passive so touch scrolling is untouched, and coarse pointers simply never subscribe: nothing is only reachable by hovering.
- Gated loop — one rAF drives both the drift and the pointer chase, suspended off screen, in a hidden tab and under reduced motion, and parked entirely once a pointer-only instance has settled; every listener, observer and custom property it sets is removed on unmount.