A full-width open menu for a small hotel, with five oversized links, a numbered index, a one-line note beside each link and a side column with address, season and a booking button. Pointing at or tabbing to a link makes every letter roll upward out of its slot while an identical letter rolls in from below, staggered from the first letter on odd rows and from the last on even rows. The preview rolls the links one after another until a visitor interacts. On phones the notes are hidden, the links drop to a smaller size and the side column moves underneath.
// Adapted from Fancy Components (https://fancycomponents.dev/docs/components/text/letter-swap)
// Copyright (c) 2024 Daniel Petho. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
const links = [
{ label: "Rooms", note: "Fourteen, all facing the water" },
{ label: "Kitchen", note: "Breakfast until noon, supper at seven" },
{ label: "Sauna", note: "Wood fired, open from dawn" },
{ label: "Journal", note: "Notes from the fjord" },
{ label: "Reserve", note: "From 240 a night" },
];
const STAGGER = 0.03; // delay between letters, in seconds
const ROLL = 0.7; // how long one letter takes to roll
const DEMO_MS = 1300; // gap between links while the preview plays on its own
type From = "first" | "last" | "center";
/** Each letter sits above a copy of itself. Rolling moves the pair up one line, so the copy lands where the letter was. */
function RollingText({ text, play, from = "first" }: { text: string; play: number; from?: From }) {
const chars = [...text];
const order = (i: number) => (from === "last" ? chars.length - 1 - i : from === "center" ? Math.abs((chars.length - 1) / 2 - i) : i);
return (
<span className="inline-flex">
<span className="sr-only">{text}</span>
{chars.map((ch, i) => (
<span key={i} aria-hidden="true" className="relative inline-block overflow-hidden whitespace-pre leading-[1.15]">
<motion.span
key={play}
className="relative inline-block"
initial={{ y: "0%" }}
animate={{ y: play ? "-100%" : "0%" }}
transition={{ type: "spring", duration: ROLL, bounce: 0.18, delay: order(i) * STAGGER }}
>
{ch}
<span className="absolute left-0 top-full">{ch}</span>
</motion.span>
</span>
))}
</span>
);
}
export default function MenuLinksWithRollingLetterSwapOnHover() {
const reduce = useReducedMotion();
const [plays, setPlays] = useState<number[]>(() => links.map(() => 0));
const [demo, setDemo] = useState(true);
const lockedUntil = useRef<number[]>(links.map(() => 0));
const roll = (i: number) => {
if (reduce) return;
const now = performance.now();
// Ignore new triggers until the current roll has finished.
if (now < lockedUntil.current[i]) return;
lockedUntil.current[i] = now + (ROLL + links[i].label.length * STAGGER) * 1000;
setPlays((p) => p.map((n, k) => (k === i ? n + 1 : n)));
};
useEffect(() => {
if (!demo || reduce) return;
let i = 0;
const id = window.setInterval(() => {
roll(i % links.length);
i++;
}, DEMO_MS);
return () => window.clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [demo, reduce]);
return (
<section className="bg-[#0d2b24] px-6 py-8 text-[#f3ead7] sm:py-10">
<div className="mx-auto flex max-w-6xl items-center justify-between">
<a href="#" className="font-serif text-xl italic tracking-tight focus:outline-none focus-visible:ring-2 focus-visible:ring-[#f3ead7]">
Nordvik House
</a>
<button type="button" aria-expanded="true" aria-controls="nordvik-menu" className="inline-flex h-11 items-center gap-3 rounded-full border border-[#f3ead7]/30 px-5 text-sm font-medium transition hover:border-[#f3ead7] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#f3ead7]">
Close
<svg aria-hidden="true" viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M5 5l10 10M15 5L5 15" />
</svg>
</button>
</div>
<div className="mx-auto mt-10 grid max-w-6xl gap-12 lg:grid-cols-[1fr_280px]">
<nav id="nordvik-menu" aria-label="Main menu" onPointerEnter={() => setDemo(false)} onFocus={() => setDemo(false)}>
<ul className="border-t border-[#f3ead7]/20">
{links.map((link, i) => (
<li key={link.label} className="border-b border-[#f3ead7]/20">
<a
href="#"
onPointerEnter={() => roll(i)}
onFocus={() => roll(i)}
className="group flex flex-wrap items-baseline gap-x-5 gap-y-1 py-4 transition-colors hover:text-[#f5c26b] focus:outline-none focus-visible:text-[#f5c26b] focus-visible:ring-2 focus-visible:ring-[#f5c26b] sm:py-5"
>
<span className="w-8 font-mono text-xs tabular-nums text-[#f3ead7]/70">{String(i + 1).padStart(2, "0")}</span>
<span className="text-5xl font-medium tracking-tight sm:text-7xl">
<RollingText text={link.label} play={plays[i]} from={i % 2 ? "last" : "first"} />
</span>
<span className="ml-auto hidden text-sm text-[#f3ead7]/75 md:block">{link.note}</span>
</a>
</li>
))}
</ul>
</nav>
<aside className="flex flex-col justify-between gap-8 text-sm leading-relaxed">
<div>
<p className="font-mono text-xs uppercase tracking-widest text-[#f5c26b]">Find us</p>
<p className="mt-3 text-[#f3ead7]/85">
Strandveien 18
<br />
On the north shore, forty minutes from the ferry
</p>
</div>
<div>
<p className="font-mono text-xs uppercase tracking-widest text-[#f5c26b]">Season</p>
<p className="mt-3 text-[#f3ead7]/85">Open March to November. The sauna runs all winter for day guests.</p>
</div>
<a href="#" className="inline-flex h-12 items-center justify-center rounded-full bg-[#f5c26b] px-6 text-sm font-semibold text-[#0d2b24] transition hover:bg-[#f8d28f] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#f3ead7] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0d2b24]">
Check availability
</a>
</aside>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/menu-links-with-rolling-letter-swap-on-hover.json
Every letter is a clipped box that contains the letter and a copy placed exactly one line below it. The animation moves the pair up by 100 percent, so the copy ends where the original started. When the next roll begins, the pair is re-mounted at its start position, which looks identical, so there is no visible reset.
Each link stores the time its current roll will finish and ignores triggers before then. That prevents a half-finished roll from snapping back when the pointer skims across the menu.
Each letter box uses a line height of 1.15, which leaves room for descenders in most fonts. If yours are clipped, raise `leading-[1.15]` on the letter span.
Yes. `RollingText` only needs a `text` and a `play` counter. Put it inside any link at any size and increase the counter in `onPointerEnter` and `onFocus`.
Start the `demo` state as `false`, or delete the effect that calls `roll` on an interval. The demo already stops for good once the pointer enters the menu.
Adapted from Letter Swap in Fancy Components, used under its open-source licence. Licences and credits
Related: Rotating Word Headline with Spring Flip, Text Scramble Decode Headline, Navbar with Centered Links, CTA Button and Mobile Menu.
More text effects or all free UI components. Start for free to have a full site built around it.
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.