"use client";
import * as React from "react";
export type MaskedTextMode = "gradient" | "noise" | "stripes" | "image";
export interface MaskedTextProps extends React.HTMLAttributes<HTMLElement> {
/** Element to render. */
as?: "h1" | "h2" | "h3" | "p" | "span" | "div";
/** Texture shown through the letterforms. */
mode?: MaskedTextMode;
/** Image URL for `mode="image"` (falls back to the gradient without one). */
src?: string;
/** First texture colour. */
accent?: string;
/** Second texture colour. */
secondary?: string;
/** Texture drift speed multiplier; 0 keeps it still. */
speed?: number;
/** Gradient / stripe angle in degrees. */
angle?: number;
/** How far the texture follows a fine pointer, in px; 0 disables parallax. */
parallax?: number;
}
/* --------------------------- reduced-motion hook --------------------------- */
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia(REDUCED_QUERY);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia(REDUCED_QUERY).matches,
() => false,
);
}
/* -------------------------------- textures -------------------------------- */
// Monochrome fractal noise with a soft alpha, tiled; blended over the colour layer for grain.
const GRAIN =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1.1 -0.25'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E\")";
const GRAIN_TILE = 160;
const STRIPE_TILE = 22;
interface Texture {
image: string;
size: string;
blend?: string;
/** Horizontal period of the moving layer in px, or "pct" for a 200%-wide gradient. */
period: number | "pct";
}
function buildTexture(mode: MaskedTextMode, src: string | undefined, angle: number): Texture {
const ink = "var(--mt-ink)";
const a = "var(--mt-accent)";
const b = "var(--mt-b)";
// A periodic gradient (same colour at both ends) that tiles seamlessly at 200% width.
const flow = `linear-gradient(${angle}deg, ${ink} 0%, ${a} 22%, ${b} 50%, ${a} 78%, ${ink} 100%)`;
switch (mode) {
case "stripes":
return {
image: `linear-gradient(45deg, ${a} 25%, ${ink} 25%, ${ink} 50%, ${a} 50%, ${a} 75%, ${ink} 75%)`,
size: `${STRIPE_TILE}px ${STRIPE_TILE}px`,
period: STRIPE_TILE,
};
case "noise":
return {
image: `${GRAIN}, linear-gradient(${angle}deg, ${a}, ${b})`,
size: `${GRAIN_TILE}px ${GRAIN_TILE}px, 100% 100%`,
blend: "multiply, normal",
period: "pct",
};
case "image":
if (src) {
// Oversized so the drift and parallax never reveal an edge.
return { image: `url(${JSON.stringify(src)})`, size: "130% auto", period: "pct" };
}
return { image: flow, size: "200% 100%", period: "pct" };
default:
return { image: flow, size: "200% 100%", period: "pct" };
}
}
/* -------------------------------- component -------------------------------- */
/**
* Display type used as a mask over a moving texture (background-clip: text). The text is real,
* selectable and read normally by assistive tech; the texture drifts from one rAF loop that
* pauses offscreen and in hidden tabs, and follows a fine pointer for a slight parallax.
* Browsers without background-clip: text get solid ink-coloured text instead.
*/
export function MaskedText({
as = "h2",
mode = "gradient",
src,
accent = "#FF7448",
secondary = "#B6F3D1",
speed = 1,
angle = 100,
parallax = 24,
className = "",
style,
children,
onPointerMove,
onPointerLeave,
...props
}: MaskedTextProps) {
const reduceMotion = usePrefersReducedMotion();
const ref = React.useRef<HTMLElement>(null);
const pointerRef = React.useRef({ x: 0, y: 0 });
const wakeRef = React.useRef<(() => void) | null>(null);
const Tag = as as React.ElementType;
const texture = buildTexture(mode, src, angle);
const { period } = texture;
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const drift = reduceMotion ? 0 : speed;
const follow = reduceMotion ? 0 : parallax;
let frame = 0;
let visible = true;
let last = performance.now();
let travel = 0; // accumulated drift in px
const shown = { x: 0, y: 0 }; // smoothed parallax offset
let grainStep = 0;
let grain = { x: 0, y: 0 };
const write = () => {
const width = el.offsetWidth || 1;
const x = travel + shown.x;
let base: string;
if (period === "pct") {
// 200%-wide image: one full period is twice the element width.
const span = width * 2;
const pos = ((x % span) + span) % span;
if (mode === "image" && src) {
// Photos do not tile, so they sway within their 30% overscan instead of scrolling.
const sway = Math.sin(travel / 160) * Math.min(width * 0.08, 48);
base = `calc(50% + ${sway + shown.x}px) calc(50% + ${shown.y}px)`;
} else {
base = `${-pos}px ${shown.y}px`;
}
} else {
base = `${x % period}px ${shown.y}px`;
}
el.style.backgroundPosition = mode === "noise" ? `${grain.x}px ${grain.y}px, ${base}` : base;
};
const tick = (now: number) => {
const dt = Math.min(64, now - last);
last = now;
travel += drift * dt * 0.04; // ~40px/s at speed 1
shown.x += (pointerRef.current.x * follow - shown.x) * 0.08;
shown.y += (pointerRef.current.y * follow * 0.5 - shown.y) * 0.08;
if (mode === "noise" && drift > 0) {
// Grain re-seeds in steps (~12 per second at speed 1) to read as film grain, not smear.
grainStep += dt * drift;
if (grainStep > 83) {
grainStep = 0;
grain = { x: Math.round(Math.random() * GRAIN_TILE), y: Math.round(Math.random() * GRAIN_TILE) };
}
}
write();
const settled =
drift === 0 &&
Math.abs(pointerRef.current.x * follow - shown.x) < 0.1 &&
Math.abs(pointerRef.current.y * follow * 0.5 - shown.y) < 0.1;
frame = settled || !visible || document.hidden ? 0 : requestAnimationFrame(tick);
};
const wake = () => {
if (frame || !visible || document.hidden) return;
last = performance.now();
frame = requestAnimationFrame(tick);
};
wakeRef.current = wake;
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible) wake();
});
io.observe(el);
const onVisibility = () => wake();
document.addEventListener("visibilitychange", onVisibility);
write();
wake();
return () => {
cancelAnimationFrame(frame);
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
wakeRef.current = null;
el.style.backgroundPosition = "";
};
}, [mode, src, period, speed, parallax, reduceMotion]);
const handlePointerMove = (event: React.PointerEvent<HTMLElement>) => {
onPointerMove?.(event);
// Parallax only for a real mouse/trackpad; touch scrolling should not drag the texture.
if (event.pointerType !== "mouse" || parallax <= 0) return;
const rect = event.currentTarget.getBoundingClientRect();
pointerRef.current = {
x: ((event.clientX - rect.left) / rect.width - 0.5) * 2,
y: ((event.clientY - rect.top) / rect.height - 0.5) * 2,
};
wakeRef.current?.();
};
const handlePointerLeave = (event: React.PointerEvent<HTMLElement>) => {
onPointerLeave?.(event);
pointerRef.current = { x: 0, y: 0 };
wakeRef.current?.();
};
return (
<Tag
ref={ref}
className={[
"w-fit max-w-full py-[0.06em] font-semibold tracking-[-0.04em] [box-decoration-break:clone] [-webkit-box-decoration-break:clone]",
// Fallback: solid ink. Only where text clipping exists do we paint the texture and hide the fill.
"text-[color:var(--mt-ink)] [--mt-ink:#18181b] dark:[--mt-ink:#fafafa]",
// On light pages a pale secondary is pulled 40% towards the ink so the letters stay legible.
"[--mt-b:color-mix(in_oklab,var(--mt-secondary)_60%,var(--mt-ink))] dark:[--mt-b:var(--mt-secondary)]",
"supports-[(background-clip:text)_or_(-webkit-background-clip:text)]:bg-clip-text",
"supports-[(background-clip:text)_or_(-webkit-background-clip:text)]:text-transparent",
"supports-[(background-clip:text)_or_(-webkit-background-clip:text)]:[background-image:var(--mt-image)]",
"supports-[(background-clip:text)_or_(-webkit-background-clip:text)]:[background-size:var(--mt-size)]",
"supports-[(background-clip:text)_or_(-webkit-background-clip:text)]:[background-blend-mode:var(--mt-blend)]",
"selection:bg-[var(--mt-accent)] selection:text-zinc-950",
"forced-colors:bg-none forced-colors:text-[CanvasText]",
className,
].join(" ")}
style={{
["--mt-accent" as string]: accent,
["--mt-secondary" as string]: secondary,
["--mt-image" as string]: texture.image,
["--mt-size" as string]: texture.size,
["--mt-blend" as string]: texture.blend ?? "normal",
backgroundRepeat: "repeat",
...style,
}}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
{...props}
>
{children}
</Tag>
);
}