Multi-line headline where a colored bar sweeps across each line, then the line slides out from behind its mask (up/down) or is wiped open with a clip-path (left/right), staggered line by line with GSAP-style eases.
Preview
Design in motion, built to be felt, not just seen.
Customize
9 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add mask-wipe-textWrites components/ui/mask-wipe-text.tsx and components/styles/mask-wipe-text.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { MaskWipeText } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <MaskWipeText />6 );7}Props
Accessibility
All lines are exposed once as a single visually hidden sentence; the animated lines and wipe bars are aria-hidden. Under prefers-reduced-motion the lines render settled and the bars are not drawn. The rAF timeline is cancelled on unmount and replays on remount.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/mask-wipe-text.module.css";5 6export type MaskWipeTextEase =7 "power2.out" | "power3.out" | "power4.out" | "expo.out" | "back.out" | "elastic.out" | "sine.inOut" | "circ.out";8 9export interface MaskWipeTextProps {10 /** Headline lines, each wiped in from behind its own mask */11 lines?: string[];12 /** Direction the text travels as it is revealed */13 direction?: "up" | "down" | "left" | "right";14 /** Show a colored bar that sweeps across each line before the text appears */15 showBar?: boolean;16 /** Color of the sweeping wipe bar */17 wipeColor?: string;18 /** GSAP-style ease name */19 ease?: MaskWipeTextEase;20 /** Duration of each line's reveal in seconds */21 duration?: number;22 /** Delay between consecutive lines in seconds */23 stagger?: number;24 /** Delay before the first line starts, in seconds */25 delay?: number;26 /** When the animation plays */27 trigger?: "mount" | "inView";28 /** Horizontal alignment of the lines */29 align?: "left" | "center";30 /** Element rendered as the root */31 as?: "h1" | "h2" | "h3" | "h4" | "p" | "div";32 /** Font size in pixels */33 fontSize?: number;34 /** Text color (defaults to the theme foreground) */35 color?: string;36 /** Optional className merged onto the root */37 className?: string;38 /** Optional inline styles merged onto the root */39 style?: React.CSSProperties;40}41 42const EASES: Record<MaskWipeTextEase, (t: number) => number> = {43 "power2.out": (t) => 1 - Math.pow(1 - t, 3),44 "power3.out": (t) => 1 - Math.pow(1 - t, 4),45 "power4.out": (t) => 1 - Math.pow(1 - t, 5),46 "expo.out": (t) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t)),47 "back.out": (t) => {48 const c = 1.70158;49 const u = t - 1;50 return 1 + (c + 1) * u * u * u + c * u * u;51 },52 "elastic.out": (t) =>53 t <= 0 ? 0 : t >= 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * ((2 * Math.PI) / 3)) + 1,54 "sine.inOut": (t) => -(Math.cos(Math.PI * t) - 1) / 2,55 "circ.out": (t) => Math.sqrt(1 - Math.pow(t - 1, 2)),56};57 58/** Smooth in-out used for the bar sweep so it reads as one deliberate stroke. */59const barEase = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);60 61const clamp01 = (v: number) => Math.min(1, Math.max(0, v));62 63const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;64 65const DEFAULT_LINES = ["Design in motion,", "built to be felt,", "not just seen."];66 67export function MaskWipeText({68 lines = DEFAULT_LINES,69 direction = "up",70 showBar = true,71 wipeColor = "#a78bfa",72 ease = "power4.out",73 duration = 0.8,74 stagger = 0.18,75 delay = 0.1,76 trigger = "mount",77 align = "center",78 as = "h2",79 fontSize = 56,80 color,81 className = "",82 style,83}: MaskWipeTextProps) {84 const rootRef = React.useRef<HTMLElement | null>(null);85 const cleanLines = lines.map((l) => l.trim()).filter(Boolean);86 const linesKey = cleanLines.join("\n");87 const horizontal = direction === "left" || direction === "right";88 const barFromRight = direction === "left";89 90 useIsoLayoutEffect(() => {91 const root = rootRef.current;92 if (!root) return;93 const texts = Array.from(root.querySelectorAll<HTMLElement>("[data-mf-mw-text]"));94 const bars = Array.from(root.querySelectorAll<HTMLElement>("[data-mf-mw-bar]"));95 const easeFn = EASES[ease] ?? EASES["power4.out"];96 const dur = Math.max(0.05, duration);97 const barDur = dur * 0.55;98 99 const setText = (el: HTMLElement, p: number | null) => {100 if (p === null) {101 el.style.transform = "";102 el.style.clipPath = "";103 el.style.opacity = "";104 return;105 }106 const inv = 1 - p;107 if (direction === "up") el.style.transform = `translate3d(0, ${(inv * 110).toFixed(2)}%, 0)`;108 else if (direction === "down") el.style.transform = `translate3d(0, ${(-inv * 110).toFixed(2)}%, 0)`;109 else {110 const shift = (direction === "right" ? -1 : 1) * inv * 0.4;111 const hidden = (clamp01(inv) * 100).toFixed(2);112 el.style.transform = `translate3d(${shift.toFixed(3)}em, 0, 0)`;113 el.style.clipPath = direction === "right" ? `inset(-10% ${hidden}% -10% 0)` : `inset(-10% 0 -10% ${hidden}%)`;114 }115 el.style.opacity = horizontal ? "1" : String(0.2 + 0.8 * clamp01(p * 1.5));116 };117 118 const setBar = (el: HTMLElement, phase: "idle" | "in" | "out", p: number) => {119 if (phase === "idle") {120 el.style.transform = "scaleX(0)";121 return;122 }123 el.style.transformOrigin =124 phase === "in" ? (barFromRight ? "100% 50%" : "0% 50%") : barFromRight ? "0% 50%" : "100% 50%";125 el.style.transform = `scaleX(${(phase === "in" ? p : 1 - p).toFixed(4)})`;126 };127 128 const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;129 if (reduced) {130 texts.forEach((el) => setText(el, null));131 bars.forEach((el) => setBar(el, "idle", 0));132 return;133 }134 135 texts.forEach((el) => setText(el, 0));136 bars.forEach((el) => setBar(el, "idle", 0));137 138 let raf = 0;139 const play = () => {140 const start = performance.now();141 const done = texts.map(() => false);142 const tick = (now: number) => {143 const t = (now - start) / 1000;144 let active = false;145 texts.forEach((el, i) => {146 if (done[i]) return;147 const lineStart = delay + i * stagger;148 const textStart = lineStart + (showBar ? barDur : 0);149 const bar = bars[i];150 if (showBar && bar) {151 const b = (t - lineStart) / barDur;152 if (b < 0) setBar(bar, "idle", 0);153 else if (b < 1) setBar(bar, "in", barEase(b));154 else if (b < 2) setBar(bar, "out", barEase(b - 1));155 else setBar(bar, "idle", 0);156 }157 const local = (t - textStart) / dur;158 if (local >= 1 && (!showBar || t - lineStart >= barDur * 2)) {159 done[i] = true;160 setText(el, null);161 if (bar) setBar(bar, "idle", 0);162 return;163 }164 active = true;165 setText(el, local <= 0 ? 0 : local >= 1 ? 1 : easeFn(local));166 });167 if (active) raf = requestAnimationFrame(tick);168 };169 raf = requestAnimationFrame(tick);170 };171 172 let io: IntersectionObserver | null = null;173 if (trigger === "inView" && typeof IntersectionObserver !== "undefined") {174 io = new IntersectionObserver(175 (entries) => {176 if (entries.some((e) => e.isIntersecting)) {177 io?.disconnect();178 play();179 }180 },181 { threshold: 0.25 },182 );183 io.observe(root);184 } else {185 play();186 }187 188 return () => {189 cancelAnimationFrame(raf);190 io?.disconnect();191 texts.forEach((el) => setText(el, null));192 bars.forEach((el) => setBar(el, "idle", 0));193 };194 }, [linesKey, direction, showBar, ease, duration, stagger, delay, trigger, horizontal, barFromRight]);195 196 const Tag = as;197 const vars = {198 "--mf-mw-size": `${fontSize}px`,199 "--mf-mw-wipe": wipeColor,200 ...(color ? { "--mf-mw-color": color } : {}),201 ...style,202 } as React.CSSProperties;203 204 return (205 <Tag206 ref={(el: HTMLElement | null) => {207 rootRef.current = el;208 }}209 className={`${styles.root} ${className}`.trim()}210 data-align={align}211 style={vars}212 >213 <span className={styles.srOnly}>{cleanLines.join(" ")}</span>214 {cleanLines.map((line, i) => (215 <span key={`${line}-${i}`} aria-hidden="true" className={styles.line}>216 <span className={styles.text} data-mf-mw-text="">217 {line}218 </span>219 {showBar ? <span className={styles.bar} data-mf-mw-bar="" /> : null}220 </span>221 ))}222 </Tag>223 );224}