Web InnoventixFreeCode

Reveal Mask Text Effect

Original · free

mask reveal heading

byWeb InnoventixReact + Tailwind
txfxrevealmasktext-effects
Open

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
txfx-reveal-mask.tsx
"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

motion

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 quote

Similar components

Browse all →