Swish UI
All componentsText Free

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.

Found on