Text

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** Fill tokens a consumer may pick — names only, so a hex can never enter the API. */
const TOKENS = {
  foreground: "var(--foreground)",
  primary: "var(--primary)",
  "muted-foreground": "var(--muted-foreground)",
  "chart-1": "var(--chart-1)",
  "chart-2": "var(--chart-2)",
  "chart-3": "var(--chart-3)",
  "chart-4": "var(--chart-4)",

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/text-clip-media.json

Prompt

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 contractbackground-clip: text keeps 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 textLength is 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 cover and the gradient tile is oversized on purpose; contain has 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.

On This Page