Rolling Letter Departure Board Headline

A tour dates board where the city name, date, venue and ticket status change by rolling one letter at a time. Every letter is hinged: the old one tips backwards out of view while the new one tips in from below, each starting a moment after its neighbour, so a wave runs across the word. The board steps to the next stop every few seconds and has previous, next and hold buttons plus a row of city tabs. On phones the city name drops to a smaller size and the tabs show three-letter codes.

Code

// Adapted from Motion Primitives (https://motion-primitives.com/docs/text-roll)
// Copyright (c) ibelick. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";

const stops = [
  { city: "LISBON", date: "FRI 07 NOV", venue: "Armazem 16", status: "ON SALE" },
  { city: "MADRID", date: "SUN 09 NOV", venue: "Sala Cobre", status: "LOW STOCK" },
  { city: "BERLIN", date: "THU 13 NOV", venue: "Kesselhaus", status: "ON SALE" },
  { city: "GLASGOW", date: "SAT 15 NOV", venue: "The Foundry", status: "SOLD OUT" },
  { city: "DUBLIN", date: "MON 17 NOV", venue: "Vicar Lane", status: "ON SALE" },
];

// Seconds each stop stays on the board.
const HOLD = 2.8;

/**
 * Every letter is two copies on a hinge: the old one tips away while the new one
 * tips in, each a beat after the letter before it. Use a monospace font so widths match.
 */
function TextRoll({
  from,
  to,
  duration = 0.42,
  stagger = 0.055,
  className = "",
}: {
  from: string;
  to: string;
  duration?: number;
  stagger?: number;
  className?: string;
}) {
  const reduceMotion = useReducedMotion();
  const length = Math.max(from.length, to.length);
  const oldLetters = from.padEnd(length).split("");
  const newLetters = to.padEnd(length).split("");
  const show = (letter: string) => (letter === " " ? " " : letter);

  if (reduceMotion) return <span className={className}>{to}</span>;
  return (
    <span className={`whitespace-nowrap ${className}`}>
      {newLetters.map((letter, index) => (
        <span key={index} aria-hidden="true" className="relative inline-block" style={{ perspective: 10000, transformStyle: "preserve-3d" }}>
          <motion.span
            className="absolute inline-block"
            style={{ backfaceVisibility: "hidden", transformOrigin: "50% 25%" }}
            initial={{ rotateX: 0 }}
            animate={{ rotateX: 90 }}
            transition={{ ease: "easeIn", duration, delay: index * stagger }}
          >
            {show(oldLetters[index])}
          </motion.span>
          <motion.span
            className="absolute inline-block"
            style={{ backfaceVisibility: "hidden", transformOrigin: "50% 100%" }}
            initial={{ rotateX: 90 }}
            animate={{ rotateX: 0 }}
            transition={{ ease: "easeOut", duration, delay: index * stagger + 0.2 }}
          >
            {show(letter)}
          </motion.span>
          <span className="invisible">{show(letter)}</span>
        </span>
      ))}
      <span className="sr-only">{to}</span>
    </span>
  );
}

export default function RollingLetterDepartureBoardHeadline() {
  const reduceMotion = useReducedMotion();
  const [index, setIndex] = useState(0);
  const [previous, setPrevious] = useState(stops.length - 1);
  const [playing, setPlaying] = useState(true);
  const sectionRef = useRef<HTMLElement>(null);

  const go = (next: number) => {
    setPrevious(index);
    setIndex((next + stops.length) % stops.length);
  };

  // Advance on a timer while the section is on screen and the tab is visible.
  useEffect(() => {
    if (!playing || reduceMotion) return;
    let visible = true;
    const observer = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
    });
    if (sectionRef.current) observer.observe(sectionRef.current);
    const timer = setInterval(() => {
      if (visible && !document.hidden) go(index + 1);
    }, HOLD * 1000);
    return () => {
      clearInterval(timer);
      observer.disconnect();
    };
  }, [playing, reduceMotion, index]);

  const stop = stops[index];
  const last = stops[previous];

  return (
    <section ref={sectionRef} className="bg-neutral-950 px-5 py-14 text-neutral-100 sm:px-8 sm:py-16">
      <div className="mx-auto max-w-4xl">
        <div className="flex flex-wrap items-center justify-between gap-4">
          <p className="text-xs font-semibold uppercase tracking-widest text-neutral-400">Marrow Club, Northbound tour</p>
          <div className="flex items-center gap-2">
            <button
              type="button"
              onClick={() => go(index - 1)}
              aria-label="Previous stop"
              className="flex h-10 w-10 items-center justify-center rounded-md border border-white/15 text-neutral-200 hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400"
            >
              <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
                <path d="M12.5 4.5 7 10l5.5 5.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </button>
            <button
              type="button"
              aria-pressed={!playing}
              onClick={() => setPlaying((value) => !value)}
              className="h-10 rounded-md border border-white/15 px-3 font-mono text-xs uppercase tracking-widest text-neutral-200 hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400"
            >
              {playing ? "Hold" : "Run"}
            </button>
            <button
              type="button"
              onClick={() => go(index + 1)}
              aria-label="Next stop"
              className="flex h-10 w-10 items-center justify-center rounded-md border border-white/15 text-neutral-200 hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400"
            >
              <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
                <path d="M7.5 4.5 13 10l-5.5 5.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </button>
          </div>
        </div>

        {/* The board */}
        <div className="mt-6 overflow-hidden rounded-xl border border-white/10 bg-neutral-900">
          <div className="flex items-center justify-between border-b border-white/10 px-5 py-3 font-mono text-xs uppercase tracking-widest text-neutral-400 sm:px-8">
            <span className="flex items-center gap-2">
              <span className="relative flex h-2 w-2">
                <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-amber-400 opacity-70 motion-reduce:animate-none" />
                <span className="relative inline-flex h-2 w-2 rounded-full bg-amber-400" />
              </span>
              Next stop
            </span>
            <span>
              {String(index + 1).padStart(2, "0")} / {String(stops.length).padStart(2, "0")}
            </span>
          </div>

          <div className="px-5 py-8 sm:px-8 sm:py-10" aria-live="polite">
            <h2 className="font-mono text-5xl font-black leading-none tracking-tight text-amber-400 sm:text-8xl">
              <TextRoll key={stop.city} from={last.city} to={stop.city} />
            </h2>
            <div className="mt-6 grid gap-x-8 gap-y-3 font-mono text-sm uppercase tracking-wider text-neutral-200 sm:grid-cols-3 sm:text-base">
              <p>
                <span className="block text-xs text-neutral-500">Date</span>
                <TextRoll key={stop.date} from={last.date} to={stop.date} stagger={0.03} />
              </p>
              <p>
                <span className="block text-xs text-neutral-500">Venue</span>
                <TextRoll key={stop.venue} from={last.venue} to={stop.venue} stagger={0.03} />
              </p>
              <p>
                <span className="block text-xs text-neutral-500">Tickets</span>
                <TextRoll key={`${stop.city}-${stop.status}`} from={last.status} to={stop.status} stagger={0.03} className={stop.status === "SOLD OUT" ? "text-rose-400" : "text-lime-300"} />
              </p>
            </div>
          </div>

          <ol className="grid grid-cols-5 border-t border-white/10">
            {stops.map((item, itemIndex) => (
              <li key={item.city} className="border-l border-white/10 first:border-l-0">
                <button
                  type="button"
                  aria-current={itemIndex === index ? "true" : undefined}
                  onClick={() => go(itemIndex)}
                  className={`relative block h-14 w-full px-1 font-mono text-xs uppercase tracking-wider focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-amber-400 ${
                    itemIndex === index ? "bg-amber-400 font-bold text-neutral-950" : "text-neutral-300 hover:bg-white/5"
                  }`}
                >
                  <span className="sm:hidden">{item.city.slice(0, 3)}</span>
                  <span className="hidden sm:inline">{item.city}</span>
                </button>
              </li>
            ))}
          </ol>
        </div>

        <div className="mt-6 flex flex-wrap items-center justify-between gap-4">
          <p className="max-w-md text-sm leading-6 text-neutral-300">Five cities in eleven days. Doors at seven, the band at nine, and the last train is usually still running.</p>
          <a
            href="#"
            className="inline-flex h-12 items-center rounded-md bg-white px-6 text-sm font-bold uppercase tracking-wider text-neutral-950 hover:bg-amber-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950"
          >
            Get tickets
          </a>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/rolling-letter-departure-board-headline.json

When to use it

How to customize it

Accessibility

Questions

How does the rolling letter effect work?

Each position holds two copies of a letter stacked on top of each other. The old one rotates on the X axis from 0 to 90 degrees, which turns it edge-on and out of sight. The new one starts at 90 degrees and rotates to 0. A per-letter delay turns that into a wave.

Why does it need a monospace font?

The old and new letters share one slot, and the slot takes the width of the new letter. With a monospaced font every slot is the same width, so the word does not shuffle sideways while it rolls.

How do I roll a word in once, without a previous word?

Pass the same string as `from` and `to`. Each letter tips away and comes straight back, which reads as a single flip on load.

Why is each TextRoll given a key?

The animation runs when the element mounts. Changing the `key` to the new value makes React mount a fresh copy, which restarts the roll from the old text to the new.

Does the timer keep running off screen?

No. An IntersectionObserver and the page visibility check skip the tick while the section is out of view or the tab is in the background.

Adapted from Text Roll in Motion Primitives, used under its open-source licence. Licences and credits

Related: Text Scramble Decode Headline, Rotating Word Headline with Spring Flip, Live News Ticker Bar with Pulsing Dot.

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