Letter swap hover
On hover or focus each letter slides up and out while a tinted copy rises from below, one letter after another.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import styles from "./demo.module.css";
const LINKS = ["Stays", "Experiences", "Hosts", "Journal"];
function Swap({ text }: { text: string }) {
return (
<span className={styles.word} aria-hidden="true">
{[...text].map((c, i) => (
<span key={i} className={styles.char} data-c={c} style={{ "--i": i } as React.CSSProperties}>
{c === " " ? " " : c}
</span>
))}
</span>
);
}
export default function Demo() {
const stay = (event: React.MouseEvent) => event.preventDefault();
return (
<div className={styles.root}>
<nav className={styles.nav} aria-label="Main">
{LINKS.map((label) => (
<a key={label} href="#" className={styles.link} aria-label={label} onClick={stay}>
<Swap text={label} />
</a>
))}
</nav>
<a href="#" className={styles.cta} aria-label="Book a stay" onClick={stay}>
<Swap text="Book a stay" />
</a>
</div>
);
}
demo.module.css
.root { container-type: inline-size; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 24px; padding: 16px; }
.nav { display: flex; flex-wrap: wrap; justify-content: center; gap: 0 4px; padding: 4px 8px; border-radius: 26px; background: var(--white); border: 1px solid var(--line); }
.link, .cta { display: inline-flex; align-items: center; min-height: 44px; padding: 0 clamp(5px, 2cqi, 10px); font-size: clamp(14px, 4.3cqi, 16px); font-weight: 600; text-decoration: none; border-radius: 999px; }
.cta { padding: 0 24px; background: var(--ink); color: var(--white); }
.word { display: inline-flex; overflow: hidden; line-height: 1.3; }
.char { position: relative; display: inline-block; transition: translate 0.45s var(--out) calc(var(--i) * 22ms); }
.char::after { content: attr(data-c); position: absolute; left: 0; top: 100%; color: var(--accent-text); }
.cta .char::after { color: var(--accent-soft); }
.link:hover .char, .link:focus-visible .char, .cta:hover .char, .cta:focus-visible .char { translate: 0 -100%; }
@media (prefers-reduced-motion: reduce) {
.char { transition: none; }
.char::after { display: none; }
.link:hover .char, .link:focus-visible .char, .cta:hover .char, .cta:focus-visible .char { translate: none; color: var(--accent-text); }
.cta:hover .char, .cta:focus-visible .char { color: var(--accent-soft); }
}
Use it for
- Top navigation and footer links on a brand site
- A short call to action that needs a little life
Avoid
- Long labels, the stagger takes too long to finish
- Dense menus and in app navigation, where speed matters more than charm
How it works
Each letter is an inline-block span inside a word with overflow hidden. A ::after pseudo element reads the same letter from a data attribute and sits at top 100 percent, just below the clip. On hover or focus-visible every letter translates by minus 100 percent, which pushes the original out and pulls the copy in. A per letter --i variable sets transition-delay to i times 22ms for the stagger.
Accessibility
Each link has an aria-label with the full word and the letter spans are aria-hidden, so it reads as one word. Focus triggers the same swap as hover. Reduced motion drops the slide and only changes the colour.