Headline Letters That React to Cursor Proximity

A dark type foundry hero whose heavy uppercase headline responds letter by letter to the pointer. Letters within reach rise, scale up from their baseline and shift from grey to lime, with a soft glow trailing behind the pointer. Three buttons switch the falloff curve between linear, exponential and gaussian so you can compare how sharp or soft the reaction feels. When nobody is moving a pointer, a demo point wanders over the headline in a figure of eight, so the effect is visible on load and on touch screens. On phones the headline wraps to more lines and the same loop runs.

Code

// Adapted from Fancy Components (https://fancycomponents.dev/docs/components/text/text-cursor-proximity)
// Copyright (c) 2024 Daniel Petho. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { useInView, useReducedMotion } from "framer-motion";

// The headline. Keep words under 10 characters so each fits one line on a phone.
const HEADLINE = "Type that leans in when you get close";

const RADIUS = 240; // how far the pointer reaches, in pixels
const LIFT = 14; // how many pixels a letter rises at full strength
const GROW = 0.18; // extra scale at full strength (0.18 = 118%)
const REST: [number, number, number] = [113, 113, 122]; // letter colour far from the pointer
const NEAR: [number, number, number] = [190, 242, 100]; // letter colour under the pointer
const IDLE_MS = 1800; // after this long without input, the demo pointer takes over

type Falloff = "linear" | "exponential" | "gaussian";
const falloffs: { id: Falloff; label: string }[] = [
  { id: "linear", label: "Linear" },
  { id: "exponential", label: "Exponential" },
  { id: "gaussian", label: "Gaussian" },
];

function strength(distance: number, mode: Falloff) {
  const n = Math.min(Math.max(1 - distance / RADIUS, 0), 1);
  if (mode === "exponential") return n * n;
  if (mode === "gaussian") return Math.exp(-Math.pow(distance / (RADIUS / 2), 2) / 2);
  return n;
}

const words = HEADLINE.split(" ");

export default function HeadlineLettersThatReactToCursorProximity() {
  const reduce = useReducedMotion();
  const sectionRef = useRef<HTMLElement>(null);
  const stageRef = useRef<HTMLDivElement>(null);
  const glowRef = useRef<HTMLDivElement>(null);
  const letterRefs = useRef<(HTMLSpanElement | null)[]>([]);
  const inView = useInView(sectionRef, { amount: 0.2 });
  const [falloff, setFalloff] = useState<Falloff>("gaussian");
  const falloffRef = useRef<Falloff>(falloff);
  falloffRef.current = falloff;

  useEffect(() => {
    const stage = stageRef.current;
    if (!stage || reduce || !inView) return;
    const letters = letterRefs.current.filter((el): el is HTMLSpanElement => !!el);
    let centers: { x: number; y: number }[] = [];
    let box = { w: 1, h: 1 };
    const measure = () => {
      box = { w: stage.offsetWidth, h: stage.offsetHeight };
      centers = letters.map((el) => ({ x: el.offsetLeft + el.offsetWidth / 2, y: el.offsetTop + el.offsetHeight / 2 }));
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(stage);

    const real = { x: 0, y: 0, at: -Infinity };
    const onMove = (e: PointerEvent) => {
      const r = stage.getBoundingClientRect();
      real.x = e.clientX - r.left;
      real.y = e.clientY - r.top;
      real.at = performance.now();
    };
    const section = sectionRef.current!;
    section.addEventListener("pointermove", onMove);
    section.addEventListener("pointerdown", onMove);

    const pos = { x: box.w * 0.2, y: box.h * 0.5 };
    const last = new Float32Array(letters.length).fill(-1);
    let raf = 0;
    const loop = (now: number) => {
      raf = requestAnimationFrame(loop);
      if (document.hidden) return;
      // With no real pointer, a demo pointer wanders over the headline in a figure of eight.
      const idle = now - real.at > IDLE_MS;
      const t = now / 1000;
      const tx = idle ? box.w * (0.5 + 0.4 * Math.sin(t * 0.9)) : real.x;
      const ty = idle ? box.h * (0.5 + 0.38 * Math.sin(t * 1.8 + 0.6)) : real.y;
      pos.x += (tx - pos.x) * 0.14;
      pos.y += (ty - pos.y) * 0.14;
      if (glowRef.current) glowRef.current.style.transform = `translate3d(${pos.x - 160}px, ${pos.y - 160}px, 0)`;
      for (let i = 0; i < letters.length; i++) {
        const c = centers[i];
        const s = strength(Math.hypot(pos.x - c.x, pos.y - c.y), falloffRef.current);
        if (Math.abs(s - last[i]) < 0.004) continue;
        last[i] = s;
        const el = letters[i];
        el.style.transform = `translate3d(0, ${(-LIFT * s).toFixed(2)}px, 0) scale(${(1 + GROW * s).toFixed(3)})`;
        el.style.color = `rgb(${REST.map((v, k) => Math.round(v + (NEAR[k] - v) * s)).join(",")})`;
      }
    };
    raf = requestAnimationFrame(loop);
    return () => {
      cancelAnimationFrame(raf);
      observer.disconnect();
      section.removeEventListener("pointermove", onMove);
      section.removeEventListener("pointerdown", onMove);
    };
  }, [reduce, inView]);

  let index = 0;
  return (
    <section ref={sectionRef} className="relative overflow-hidden bg-zinc-950 px-6 py-20 text-zinc-100 sm:py-24">
      <div className="relative mx-auto max-w-6xl">
        <div className="flex flex-wrap items-center justify-between gap-4">
          <p className="font-mono text-xs uppercase tracking-[0.25em] text-lime-300">Quarto Type Foundry</p>
          <div role="group" aria-label="Falloff curve" className="flex rounded-full border border-zinc-800 bg-zinc-900 p-1">
            {falloffs.map((f) => (
              <button
                key={f.id}
                type="button"
                aria-pressed={falloff === f.id}
                onClick={() => setFalloff(f.id)}
                className={`h-10 rounded-full px-4 text-xs font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-lime-300 ${falloff === f.id ? "bg-lime-300 text-zinc-950" : "text-zinc-300 hover:text-white"}`}
              >
                {f.label}
              </button>
            ))}
          </div>
        </div>

        <div ref={stageRef} className="relative mt-12 sm:mt-16">
          <div ref={glowRef} aria-hidden="true" className="pointer-events-none absolute left-0 top-0 h-80 w-80 rounded-full bg-lime-300/10 blur-3xl" />
          <h2 className="relative select-none text-5xl font-black uppercase leading-[1.05] tracking-tight sm:text-7xl lg:text-8xl">
            <span className="sr-only">{HEADLINE}</span>
            <span aria-hidden="true">
              {words.map((word, w) => (
                <span key={w}>
                  <span className="inline-block whitespace-nowrap">
                    {[...word].map((ch) => {
                      const i = index++;
                      return (
                        <span
                          key={i}
                          ref={(el) => {
                            letterRefs.current[i] = el;
                          }}
                          className="inline-block origin-bottom will-change-transform"
                          style={{ color: `rgb(${(reduce ? [228, 228, 231] : REST).join(",")})` }}
                        >
                          {ch}
                        </span>
                      );
                    })}
                  </span>{" "}
                </span>
              ))}
            </span>
          </h2>
        </div>

        <div className="mt-12 grid gap-8 border-t border-zinc-800 pt-8 sm:mt-16 lg:grid-cols-[1fr_auto] lg:items-end">
          <p className="max-w-xl text-base leading-relaxed text-zinc-400 sm:text-lg">
            Quarto Grotesk ships in nine weights with a display cut made for posters and landing pages. Move across the headline to feel how it responds.
          </p>
          <div className="flex flex-wrap gap-3">
            <a href="#" className="inline-flex h-12 items-center rounded-full bg-lime-300 px-7 text-sm font-semibold text-zinc-950 transition hover:bg-lime-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-lime-300 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950">
              Try the typeface
            </a>
            <a href="#" className="inline-flex h-12 items-center rounded-full border border-zinc-700 px-7 text-sm font-semibold text-zinc-100 transition hover:border-zinc-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-lime-300 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950">
              See licences
            </a>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/headline-letters-that-react-to-cursor-proximity.json

When to use it

How to customize it

Accessibility

Questions

How does the proximity effect know where each letter is?

Every letter is an inline-block span. On mount and on resize the component stores each span's centre from `offsetLeft` and `offsetTop`, which are not affected by transforms. Each frame it measures the distance from the pointer to those stored centres, so there are no layout reads inside the animation loop.

What is the difference between the three falloff curves?

Linear fades evenly from the pointer to the edge of the radius. Exponential squares that value, so only letters very close react strongly. Gaussian is a bell curve: a soft peak with a smooth tail and no visible edge.

Will it work on phones?

Yes. Pointer events cover touch, so dragging a finger across the headline drives it, and the demo pointer takes over when the finger lifts.

Is it expensive to run?

The loop touches only `transform` and `color` on a few dozen spans, skips letters whose value has not changed, and stops when the section is off screen or the tab is hidden.

Can I animate other properties, such as font weight?

Yes. Inside the loop, `s` is the strength from 0 to 1 for the letter. With a variable font you can set `el.style.fontVariationSettings` from it. Avoid properties that change letter width unless your layout can absorb the movement.

Adapted from Text Cursor Proximity in Fancy Components, used under its open-source licence. Licences and credits

Related: Text Scramble Decode Headline, Shimmering Chrome Gradient Headline, Magnetic Button That Follows the Pointer.

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

More in Text Effects

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