Text FXAdvanced Text Animationsno dependencies

MaskWipeText

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 props
0.8
0.18
56

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add mask-wipe-text

Writes 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.

example.tsx
1import { MaskWipeText } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <MaskWipeText />6  );7}

Props

PropTypeDefault
lines

Headline lines, each wiped in from behind its own mask.

string[]['Design in motion,', 'built to be felt,', 'not just seen.']
direction

Direction the text travels as it is revealed.

"up" | "down" | "left" | "right"'up'
showBar

Show a colored bar that sweeps across each line before the text appears.

booleantrue
wipeColor

Color of the sweeping wipe bar.

string'#a78bfa'
ease

GSAP-style ease name.

"power2.out" | "power3.out" | "power4.out" | "expo.out" | "back.out" | "elastic.out" | "sine.inOut" | "circ.out"'power4.out'
duration

Duration of each line's reveal in seconds.

number0.8
stagger

Delay between consecutive lines in seconds.

number0.18
delay

Delay before the first line starts, in seconds.

number0.1
trigger

When the animation plays.

"mount" | "inView"'mount'
align

Horizontal alignment of the lines.

"left" | "center"'center'
as

Element rendered as the root.

"h1" | "h2" | "h3" | "h4" | "p" | "div"'h2'
fontSize

Font size in pixels.

number56
color

Text color (defaults to the theme foreground).

stringundefined
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element (CSS variables included).

React.CSSPropertiesundefined

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

components/ui/mask-wipe-text.tsx
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}