# Rotating Headline Scrambles Between Three Phrases

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/rotating-headline-scrambles-between-three-phrases).

- Page, with a live preview: https://shadcn.exchange/r/weinc/rotating-headline-scrambles-between-three-phrases
- Screenshot: https://shadcn.exchange/shot/weinc/rotating-headline-scrambles-between-three-phrases/light.png
- Registry item: https://we.inc/r/rotating-headline-scrambles-between-three-phrases.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/rotating-headline-scrambles-between-three-phrases.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Files

### `components/we-inc/rotating-headline-scrambles-between-three-phrases.tsx`

```tsx
import { useEffect, useRef, useState } from "react";

const PHRASES = ["heritage wheat.", "whole spelt.", "dark rye."]; // keep them short; the longest sets the width
const LABELS = ["Wheat", "Spelt", "Rye"];
const GLYPHS = "abcdefghkmnprstuvwxyz#/+=";
const STEP = 42; // ms between one letter settling and the next
const LEAD = 180; // ms of noise before the first letter settles
const HOLD = 3600; // ms each phrase rests before the next one
const W = Math.max(...PHRASES.map((p) => p.length));
const pad = (s: string) => s.padEnd(W, " ");

export default function RotatingHeadlineScramblesBetweenThreePhrases() {
  const root = useRef<HTMLElement>(null);
  const out = useRef<HTMLSpanElement>(null);
  const goRef = useRef<(n: number, manual?: boolean) => void>(() => {});
  const [cur, setCur] = useState(0);

  useEffect(() => {
    const el = root.current;
    const o = out.current;
    if (!el || !o) return;
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    let n = 0;
    let f = 0;
    let timer = 0;
    let auto = true;
    let on = false;
    const go = (next: number, manual = false) => {
      if (manual) auto = false;
      n = next;
      setCur(next);
      cancelAnimationFrame(f);
      clearTimeout(timer);
      if (reduce) { o.textContent = PHRASES[next]; return; }
      const to = pad(PHRASES[next]);
      const from = pad(o.textContent || "");
      const t0 = performance.now();
      const frame = (now: number) => {
        const t = now - t0;
        let s = "";
        let done = true;
        for (let i = 0; i < W; i++) {
          if (t > LEAD + i * STEP) s += to[i];
          else {
            done = false;
            s += to[i] === " " && from[i] === " " ? " " : GLYPHS[(Math.random() * GLYPHS.length) | 0];
          }
        }
        o.textContent = s;
        f = done ? 0 : requestAnimationFrame(frame);
        if (done && auto && on) timer = window.setTimeout(() => go((n + 1) % PHRASES.length), HOLD);
      };
      frame(t0);
    };
    goRef.current = go;
    if (reduce) return;
    const h = el.querySelector("h2");
    const hover = () => { if (!f) go(n); };
    h?.addEventListener("pointerenter", hover);
    const io = new IntersectionObserver(([e]) => {
      on = e.isIntersecting;
      clearTimeout(timer);
      if (on && auto) go(n);
    });
    io.observe(el);
    return () => {
      io.disconnect();
      cancelAnimationFrame(f);
      clearTimeout(timer);
      h?.removeEventListener("pointerenter", hover);
    };
  }, []);

  return (
    <section ref={root} className="bg-[#1c2a21] px-5 py-[72px] text-[#f1ead9]">
      <style>{`
        .w3rph-word { font: 600 .82em/1.15 ui-monospace, "SF Mono", Menlo, Consolas, monospace; letter-spacing: -.03em; white-space: pre; }
        .w3rph-pick:focus-visible { outline: 2px solid #e6b860; outline-offset: 3px; }
      `}</style>
      <div className="mx-auto grid max-w-[68rem] items-center gap-10 min-[860px]:grid-cols-[1fr_380px]">
        <div>
          <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[#a9bba5]">Fennick Mill</p>
          <h2 className="mt-[18px] font-serif text-[clamp(36px,5.6vw,64px)] font-normal leading-[1.08] tracking-[-0.02em]">
            <span className="sr-only">We stone-mill heritage wheat, whole spelt and dark rye.</span>
            <span aria-hidden="true" className="block">We stone-mill</span>
            <span ref={out} aria-hidden="true" className="w3rph-word mt-1.5 block text-[#e6b860]">{pad(PHRASES[0])}</span>
          </h2>
          <p className="mt-6 max-w-[32rem] text-[17px] leading-relaxed text-[#c9cdbd]">
            Grain from three farms within a morning's drive, ground slowly between French burr stones and bagged the same week.
          </p>
          <div className="mt-8 flex gap-2" role="group" aria-label="Show a flour">
            {LABELS.map((l, i) => (
              <button
                key={l}
                type="button"
                aria-pressed={cur === i}
                onClick={() => goRef.current(i, true)}
                className={`w3rph-pick min-h-[44px] rounded-full border px-[18px] text-sm font-medium ${
                  cur === i ? "border-[#f1ead9] bg-[#f1ead9] text-[#1c2a21]" : "border-[#3d5244] hover:border-[#6d8670]"
                }`}
              >
                {l}
              </button>
            ))}
          </div>
        </div>
        <img
          src="https://images.pexels.com/photos/6294375/pexels-photo-6294375.jpeg?auto=compress&cs=tinysrgb&w=900"
          alt="A steel scoop resting in an open paper sack of fresh white flour"
          width={900}
          height={1125}
          loading="lazy"
          className="block aspect-[4/5] h-auto w-full max-w-[380px] rounded-[18px] object-cover shadow-[0_30px_60px_-30px_rgba(0,0,0,0.6)]"
        />
      </div>
    </section>
  );
}
```
