Reveal Mask Text Effect
Original · freemask reveal heading
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/txfx-reveal-mask.json"use client";
import { useRef, useState, type KeyboardEvent } from "react";
import { motion, useInView, useReducedMotion, type Variants } from "motion/react";
type RevealId = "rise" | "wipe" | "split" | "focus";
const STYLES: ReadonlyArray<{ id: RevealId; label: string; hint: string }> = [
{ id: "rise", label: "Rise", hint: "Words lift from behind a hard mask edge." },
{ id: "wipe", label: "Wipe", hint: "A left-to-right clip sweeps each word open." },
{ id: "split", label: "Split", hint: "Each word unclips outward from its centre." },
{ id: "focus", label: "Focus", hint: "Type resolves from a soft, blurred haze." },
];
const LINES: ReadonlyArray<ReadonlyArray<{ t: string; em?: boolean }>> = [
[{ t: "Great" }, { t: "products" }],
[{ t: "earn" }, { t: "your" }, { t: "attention", em: true }],
[{ t: "one" }, { t: "line" }, { t: "at" }, { t: "a" }, { t: "time." }],
];
const INDEXED_LINES = (() => {
let n = 0;
return LINES.map((line) => line.map((w) => ({ ...w, i: n++ })));
})();
const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];
function makeItemVariants(style: RevealId, reduced: boolean): Variants {
if (reduced) {
return {
hidden: { opacity: 1, y: "0%", filter: "blur(0px)", clipPath: "inset(0 0 0 0)" },
visible: { opacity: 1, y: "0%", filter: "blur(0px)", clipPath: "inset(0 0 0 0)", transition: { duration: 0 } },
};
}
const delay = (i: number): number => 0.14 + i * 0.055;
switch (style) {
case "rise":
return {
hidden: { y: "118%" },
visible: (i: number) => ({ y: "0%", transition: { duration: 0.72, ease: EASE, delay: delay(i) } }),
};
case "wipe":
return {
hidden: { clipPath: "inset(0 100% 0 -0.06em)", opacity: 0.15 },
visible: (i: number) => ({
clipPath: "inset(0 -0.06em 0 -0.06em)",
opacity: 1,
transition: { duration: 0.62, ease: EASE, delay: delay(i) },
}),
};
case "split":
return {
hidden: { clipPath: "inset(0 50% 0 50%)", opacity: 0 },
visible: (i: number) => ({
clipPath: "inset(0 -0.06em 0 -0.06em)",
opacity: 1,
transition: { duration: 0.66, ease: EASE, delay: delay(i) },
}),
};
case "focus":
return {
hidden: { opacity: 0, y: "28%", filter: "blur(14px)" },
visible: (i: number) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { duration: 0.7, ease: EASE, delay: delay(i) },
}),
};
default:
return { hidden: {}, visible: {} };
}
}
export default function TxfxRevealMask() {
const prefersReduced = useReducedMotion();
const reduced = !!prefersReduced;
const [style, setStyle] = useState<RevealId>("rise");
const [runId, setRunId] = useState<number>(0);
const sceneRef = useRef<HTMLHeadingElement>(null);
const inView = useInView(sceneRef, { amount: 0.4 });
const optionRefs = useRef<(HTMLButtonElement | null)[]>([]);
const item = makeItemVariants(style, reduced);
const needsMask = style === "rise";
const activeHint = STYLES.find((s) => s.id === style)?.hint ?? "";
const replay = (): void => setRunId((n) => n + 1);
const selectStyle = (id: RevealId): void => {
setStyle(id);
setRunId((n) => n + 1);
};
const onRadioKey = (e: KeyboardEvent<HTMLButtonElement>, idx: number): void => {
let next = idx;
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (idx + 1) % STYLES.length;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (idx - 1 + STYLES.length) % STYLES.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = STYLES.length - 1;
else return;
e.preventDefault();
selectStyle(STYLES[next].id);
optionRefs.current[next]?.focus();
};
return (
<section className="relative w-full overflow-hidden bg-white px-6 py-24 text-zinc-900 sm:py-32 dark:bg-zinc-950 dark:text-zinc-50">
<style>{`
@keyframes txfxrm-drift { 0%,100% { transform: translate3d(0,0,0) } 50% { transform: translate3d(0,-20px,0) } }
@keyframes txfxrm-pulse { 0%,100% { opacity:.4; transform: scale(1) } 50% { opacity:1; transform: scale(1.4) } }
.txfxrm-blob { animation: txfxrm-drift 10s ease-in-out infinite; }
.txfxrm-dot { animation: txfxrm-pulse 2.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.txfxrm-blob, .txfxrm-dot { animation: none !important; }
}
`}</style>
<div aria-hidden className="txfxrm-blob pointer-events-none absolute -top-24 left-[18%] h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/25" />
<div aria-hidden className="txfxrm-blob pointer-events-none absolute -bottom-28 right-[14%] h-80 w-80 rounded-full bg-violet-400/20 blur-3xl dark:bg-violet-500/20" style={{ animationDelay: "-3.5s" }} />
<div className="relative mx-auto max-w-4xl text-center">
<div className="mb-8 inline-flex items-center gap-2.5 rounded-full border border-zinc-200 bg-zinc-50 px-3.5 py-1.5 text-xs font-medium tracking-wide text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300">
<span className="txfxrm-dot inline-block h-1.5 w-1.5 rounded-full bg-indigo-500" />
Text effects
<span aria-hidden className="text-zinc-300 dark:text-zinc-700">/</span>
Mask reveal
</div>
<h2
ref={sceneRef}
className="text-balance text-4xl font-semibold leading-[1.04] tracking-tight sm:text-6xl lg:text-7xl"
>
<motion.span
key={`${style}-${runId}`}
initial="hidden"
animate={inView ? "visible" : "hidden"}
className="block"
>
{INDEXED_LINES.map((line, li) => (
<span key={li} className="block">
{line.map((w, wi) => (
<span key={wi}>
<span
className={needsMask ? "inline-block overflow-hidden align-bottom" : "inline-block align-bottom"}
style={needsMask ? { paddingBottom: "0.14em", marginBottom: "-0.14em" } : undefined}
>
<motion.span
variants={item}
custom={w.i}
className={
w.em
? "inline-block bg-gradient-to-r from-indigo-500 via-violet-500 to-sky-500 bg-clip-text text-transparent will-change-transform"
: "inline-block will-change-transform"
}
>
{w.t}
</motion.span>
</span>
{wi < line.length - 1 ? " " : null}
</span>
))}
</span>
))}
</motion.span>
</h2>
<motion.p
key={`desc-${style}-${runId}`}
initial={{ opacity: 0, y: 12 }}
animate={reduced || inView ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
transition={{ duration: reduced ? 0 : 0.6, ease: EASE, delay: reduced ? 0 : 0.7 }}
className="mx-auto mt-7 max-w-xl text-pretty text-base leading-relaxed text-zinc-600 sm:text-lg dark:text-zinc-400"
>
A mask-reveal heading built with Motion. Choose a reveal style, replay it, and every
option respects your reduced-motion setting.
</motion.p>
<div className="mt-11 flex flex-col items-center justify-center gap-4 sm:flex-row">
<div
role="radiogroup"
aria-label="Reveal style"
className="inline-flex items-center gap-1 rounded-full border border-zinc-200 bg-zinc-100 p-1 dark:border-zinc-800 dark:bg-zinc-900"
>
{STYLES.map((s, idx) => {
const selected = s.id === style;
return (
<button
key={s.id}
ref={(el) => {
optionRefs.current[idx] = el;
}}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => selectStyle(s.id)}
onKeyDown={(e) => onRadioKey(e, idx)}
className={`relative rounded-full px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900 ${
selected
? "text-zinc-900 dark:text-white"
: "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
}`}
>
{selected ? (
<motion.span
aria-hidden
layoutId="txfxrm-pill"
transition={{ duration: reduced ? 0 : 0.32, ease: EASE }}
className="absolute inset-0 rounded-full bg-white shadow-sm ring-1 ring-black/5 dark:bg-zinc-700 dark:ring-white/10"
/>
) : null}
<span className="relative z-10">{s.label}</span>
</button>
);
})}
</div>
<button
type="button"
onClick={replay}
className="inline-flex items-center gap-2 rounded-full bg-zinc-900 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-zinc-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200 dark:focus-visible:ring-offset-zinc-950"
>
<motion.svg
aria-hidden
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
animate={{ rotate: reduced ? 0 : runId * 360 }}
transition={{ duration: reduced ? 0 : 0.6, ease: "easeInOut" }}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 3v6h-6" />
</motion.svg>
Replay
</button>
</div>
<p aria-live="polite" className="mt-5 text-xs text-zinc-400 dark:text-zinc-500">
{activeHint} <span className="text-zinc-300 dark:text-zinc-600">Use arrow keys to switch styles.</span>
</p>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Aurora Gradient Headline
OriginalAn animated headline whose living multi-colour gradient drifts across every letter while the words blur and stagger into place on scroll.

Character Reveal Heading
OriginalA cinematic heading that blurs and lifts in one character at a time with a staggered, scroll-triggered reveal.

Shimmer Sweep Headline
OriginalA headline with a polished light sheen that glides endlessly across the letters and races faster on hover.

Rotating Words Headline
OriginalA headline whose final word flips through a curated set with a blurred vertical roll while the rest of the line stays perfectly still.

Gradient Animate Text Effect
Originalanimated gradient text

Typing Text Effect
Originaltypewriter text with caret

Shiny Text Effect
Originalshiny sweep text

Glitch Text Effect
Originalglitch text effect

Wave Text Effect
Originalwavy letter animation

Outline Text Effect
Originaloutline-to-fill text

3D Text Effect
Originallayered 3D text

Scramble Text Effect
Originalscramble/decode text

