Headline That Splits Open Under the Pointer

A poster-size headline sliced horizontally through the middle. Under the pointer the top half of a letter lifts and the bottom half drops, the neighbouring letters follow by smaller amounts, and a strip of small print shows through the gap. With no pointer on it, a slow wave walks across the word and back so the effect is visible straight away. Below the headline sit a paragraph and two buttons for a gig listing. The type scales with the screen width, so the word fills a phone from edge to edge and a finger works the same as a mouse.

Code

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

const WORD = "LOUDER";
const STRIP = "Fri 14 Mar · Doors 7pm · The Boiler Room, Leith · Fri 14 Mar · Doors 7pm · The Boiler Room, Leith";
// How far each half moves, in em, at the pointer and one, two and three letters away.
const SPREAD = [0.17, 0.11, 0.05, 0.015];

export default function HeadlineThatSplitsOpenUnderThePointer() {
  const [active, setActive] = useState<number | null>(null);
  const [idle, setIdle] = useState(true);
  const rowRef = useRef<HTMLSpanElement>(null);
  const letters = WORD.split("");

  // With no pointer on it, a slow wave walks across the word so the split is visible in a preview.
  useEffect(() => {
    if (!idle) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setActive(Math.floor(letters.length / 2));
      return;
    }
    let step = 0;
    const path = [...letters.map((_, i) => i), ...letters.map((_, i) => letters.length - 1 - i)];
    setActive(path[0]);
    const timer = window.setInterval(() => {
      step = (step + 1) % path.length;
      setActive(path[step]);
    }, 420);
    return () => window.clearInterval(timer);
  }, [idle, letters.length]);

  const track = (event: PointerEvent<HTMLSpanElement>) => {
    const row = rowRef.current;
    if (!row) return;
    const box = row.getBoundingClientRect();
    const ratio = (event.clientX - box.left) / box.width;
    setIdle(false);
    setActive(Math.min(letters.length - 1, Math.max(0, Math.floor(ratio * letters.length))));
  };

  const offset = (index: number) => {
    if (active === null) return 0;
    return SPREAD[Math.abs(index - active)] ?? 0;
  };
  const half = "block h-1/2 select-none overflow-hidden whitespace-pre leading-none transition-transform duration-300 ease-out motion-reduce:transition-none";

  return (
    <section className="bg-zinc-950 px-4 py-16 sm:px-6 sm:py-24">
      <div className="mx-auto flex max-w-5xl flex-col items-center text-center">
        <p className="text-sm font-semibold uppercase tracking-[0.3em] text-zinc-400">Northside Sessions presents</p>

        <h2 className="relative mt-8 text-[20vw] font-black uppercase leading-none text-lime-300 sm:text-[9.5rem] lg:text-[12rem]">
          <span className="sr-only">{WORD}</span>
          {/* Invisible copy keeps the block the right size for any word. */}
          <span className="invisible block leading-none" aria-hidden="true">
            {WORD}
          </span>

          {/* The strip that shows through the gap. */}
          <span
            aria-hidden="true"
            className="absolute inset-x-0 top-1/2 flex h-[0.3em] -translate-y-1/2 items-center justify-center overflow-hidden whitespace-nowrap bg-zinc-950 text-[0.11em] font-semibold tracking-[0.2em] text-zinc-100"
          >
            {STRIP}
          </span>

          <span
            ref={rowRef}
            aria-hidden="true"
            onPointerMove={track}
            onPointerDown={track}
            onPointerLeave={() => setIdle(true)}
            className="absolute inset-0 flex cursor-crosshair touch-pan-y justify-center"
          >
            {letters.map((letter, index) => (
              <span key={`${letter}-${index}`} className="relative inline-flex h-full flex-col leading-none">
                <span className={half} style={{ transform: `translateY(-${offset(index)}em)` }}>
                  {letter}
                </span>
                <span className={half} style={{ transform: `translateY(${offset(index)}em)` }}>
                  <span className="block -translate-y-1/2 leading-none">{letter}</span>
                </span>
              </span>
            ))}
          </span>
        </h2>

        <p className="mt-10 max-w-lg text-base leading-relaxed text-zinc-300">
          Five bands, one room, no seats. Run a pointer or a finger across the headline to prise it open and read the details
          hiding in the middle.
        </p>
        <div className="mt-8 flex flex-wrap items-center justify-center gap-3">
          <a
            href="#"
            className="inline-flex h-12 items-center rounded-full bg-lime-300 px-7 text-sm font-bold text-zinc-950 transition hover:bg-lime-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-lime-200 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
          >
            Get tickets, £18
          </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-visible:outline-none focus-visible:ring-2 focus-visible:ring-lime-200 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
          >
            See the line-up
          </a>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/headline-that-splits-open-under-the-pointer.json

When to use it

How to customize it

Accessibility

Questions

How is each letter cut in half?

Every letter is rendered twice in a column that is exactly one line tall. The first copy sits in a box half that height with hidden overflow, so only its top shows. The second box is also half height, and the letter inside is shifted up by half, so only its bottom shows. Moving the two boxes apart opens the cut.

Why are the offsets in em?

The headline changes size with the screen. Offsets in em scale with the font size, so the gap is the same proportion of the letter on a phone and on a wide monitor.

How does it know which letter the pointer is over?

It divides the pointer's horizontal position by the width of the row and multiplies by the number of letters. That is slightly approximate for narrow letters such as I, but it avoids a listener on every letter and works for touch drags.

Does it work with any font?

It works best with heavy fonts in capitals, where the middle of each letter has plenty of ink. Fonts with tall line spacing may need `leading-none` kept on every layer so the cut stays at the visual middle.

Can the page still be scrolled by touch over the headline?

Yes. The row uses `touch-pan-y`, so vertical swipes scroll the page and horizontal movement opens the letters.

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

Related: Headline Letters That React to Cursor Proximity, Poster Headline with 3D Flipping Letter Blocks, Glitch Text Headline with Colour Split Layers.

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