Cursor Mask That Reveals Hidden Text Underneath

A dark statement panel with a second message hidden in the same place. A small orange circle follows the pointer on a spring, and inside it the alternative wording shows in dark type on orange. Move over the sentence and the circle swells to a wide lens with a little bounce; leave and it shrinks again. Before anyone touches it the lens drifts in a slow figure of eight so the idea is clear at a glance, and a button opens the hidden layer completely for keyboard and touch users. Text sizes step down on phones and a finger drag moves the lens.

Code

// Adapted from Animata (https://animata.design/docs/text/mask-text)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import type { PointerEvent } from "react";
import { motion, useMotionTemplate, useMotionValue, useReducedMotion, useSpring } from "framer-motion";

const copy = {
  label: "About the studio",
  official: ["We design calm,", "considered brands", "for companies built", "to last fifty years."],
  honest: ["We argue over fonts,", "drink too much coffee", "and redraw the logo", "forty times a week."],
};
const SMALL = 36; // radius of the lens while it wanders
const LARGE = 190; // radius while it is over the text

export default function CursorMaskThatRevealsHiddenTextUnderneath() {
  const reduce = useReducedMotion();
  const frameRef = useRef<HTMLDivElement>(null);
  const [open, setOpen] = useState(false);
  const [engaged, setEngaged] = useState(false);

  const x = useMotionValue(320);
  const y = useMotionValue(180);
  const radius = useMotionValue(SMALL);
  const sx = useSpring(x, { stiffness: 170, damping: 22, mass: 0.6 });
  const sy = useSpring(y, { stiffness: 170, damping: 22, mass: 0.6 });
  const sr = useSpring(radius, { stiffness: 190, damping: 17 });
  const mask = useMotionTemplate`radial-gradient(circle ${sr}px at ${sx}px ${sy}px, #000 99%, transparent 100%)`;

  // Until someone points at it, the lens drifts in a slow figure of eight and swells over the text.
  useEffect(() => {
    const frame = frameRef.current;
    if (!frame || engaged || open || reduce) return;
    let raf = 0;
    let visible = true;
    const start = performance.now();
    const tick = (now: number) => {
      const t = (now - start) / 1000;
      const w = frame.clientWidth;
      const h = frame.clientHeight;
      x.set(w / 2 + Math.sin(t * 0.7) * w * 0.3);
      y.set(h / 2 + Math.sin(t * 1.4) * h * 0.2);
      radius.set(SMALL + (LARGE * 0.72 - SMALL) * (0.5 + 0.5 * Math.sin(t * 0.9 - 0.6)));
      if (visible) raf = requestAnimationFrame(tick);
    };
    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      cancelAnimationFrame(raf);
      if (visible) raf = requestAnimationFrame(tick);
    });
    io.observe(frame);
    return () => {
      visible = false;
      cancelAnimationFrame(raf);
      io.disconnect();
    };
  }, [engaged, open, reduce, radius, x, y]);

  // The button (and reduced motion) opens the whole hidden layer, so nothing depends on a pointer.
  useEffect(() => {
    if (open) radius.set(2400);
    else radius.set(reduce ? 0 : SMALL);
  }, [open, reduce, radius]);

  const move = (event: PointerEvent<HTMLDivElement>) => {
    const box = event.currentTarget.getBoundingClientRect();
    x.set(event.clientX - box.left);
    y.set(event.clientY - box.top);
    if (!engaged) setEngaged(true);
    if (!open) {
      const overText = (event.target as HTMLElement).closest("[data-lens-text]") !== null;
      radius.set(overText ? LARGE : SMALL);
    }
  };

  const lines = (list: string[]) =>
    list.map((line) => (
      <span key={line} className="block">
        {line}
      </span>
    ));
  const layer = "absolute inset-0 flex flex-col items-center justify-center px-6 text-center";
  const statement = "max-w-3xl text-2xl font-semibold leading-snug tracking-tight sm:text-4xl lg:text-5xl lg:leading-tight";

  return (
    <section className="bg-stone-950 px-4 py-10 sm:px-6 sm:py-14">
      <div className="mx-auto max-w-6xl">
        <div
          ref={frameRef}
          onPointerMove={move}
          onPointerDown={move}
          onPointerLeave={() => {
            if (!open) radius.set(SMALL);
            setEngaged(false);
          }}
          className="relative h-[460px] touch-pan-y overflow-hidden rounded-3xl border border-stone-800 bg-stone-900 sm:h-[480px]"
        >
          {/* What everyone sees. */}
          <div className={layer}>
            <p className="text-xs font-semibold uppercase tracking-[0.3em] text-stone-400">{copy.label}</p>
            <p data-lens-text className={`mt-6 text-stone-100 ${statement}`}>
              {lines(copy.official)}
            </p>
          </div>

          {/* The same block in loud colours, visible only inside the circle. */}
          <motion.div
            aria-hidden={!open}
            className={`${layer} bg-orange-500`}
            style={{ WebkitMaskImage: mask, maskImage: mask }}
          >
            <p className="text-xs font-semibold uppercase tracking-[0.3em] text-stone-900">Between ourselves</p>
            <p data-lens-text className={`mt-6 text-stone-950 ${statement}`}>
              {lines(copy.honest)}
            </p>
          </motion.div>

          <div className="absolute inset-x-0 bottom-5 flex justify-center">
            <button
              type="button"
              aria-pressed={open}
              onClick={() => setOpen((current) => !current)}
              className={`inline-flex h-11 items-center gap-2 rounded-full px-5 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${
                open
                  ? "bg-stone-950 text-white focus-visible:ring-stone-950 focus-visible:ring-offset-orange-500"
                  : "bg-stone-100 text-stone-950 focus-visible:ring-orange-400 focus-visible:ring-offset-stone-900"
              }`}
            >
              <svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
                <path d="M1.5 10S4.5 4.5 10 4.5 18.5 10 18.5 10 15.5 15.5 10 15.5 1.5 10 1.5 10Z" />
                <circle cx="10" cy="10" r="2.5" />
              </svg>
              {open ? "Back to the official line" : "Show the honest version"}
            </button>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/cursor-mask-that-reveals-hidden-text-underneath.json

When to use it

How to customize it

Accessibility

Questions

How does the mask follow the pointer?

The top layer has a `mask-image` made of a radial gradient: black inside a circle and transparent outside. The circle's centre and radius are framer-motion springs joined into one string with `useMotionTemplate`, so the style updates without re-rendering React.

Why use a gradient instead of an SVG circle as the mask?

A gradient needs no file and can be resized and moved by changing numbers in the string. The `99%` and `100%` stops give the circle a clean edge with one pixel of smoothing.

How do I keep the two messages lined up?

Both layers use the same wrapper classes, the same type classes and the same number of lines. Each line is a block-level span, so line breaks are fixed by you and do not depend on how the browser wraps each sentence.

Does it work on touch screens?

Yes. Pointer events fire for touch, so pressing or dragging on the panel moves the lens. The panel uses `touch-pan-y`, which keeps vertical page scrolling working.

Can the lens reveal an image instead of text?

Yes. Put any content in the masked layer: a colour photograph over a greyscale one, a finished design over a wireframe, or a map over a list.

Adapted from Mask Text in Animata, used under its open-source licence. Licences and credits

Related: Custom Cursor Follower with View Label on Cards, Spotlight Cards with Pointer Light, Before and After Comparison Slider with Spring Handle.

More cursor effects or all free UI components. Start for free to have a full site built around it.

More in Cursors

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap