Most hover text swaps feel cheap: opacity fades, layout jumps, or the new string just appears. I wanted something closer to what you see on polished personal sites, where the swap feels physical without breaking the line.
That is what HoverSwapText does. Hover a phrase and it reveals alternate copy with a vertical slide. Same-length pairs animate character by character. Mismatched pairs slide as one block.
Try it
Preview — hover the swap target
I spend most of my time Building interfacesBuilding interfacesBuilding interfaces.
Edit the before/after copy, swap strings, and drag the stagger slider. Words are aligned independently, so spaces stay in sync even when lengths differ.
Why not a simple crossfade?
Crossfades are easy, but they fight inline text. The old string disappears while the new one fades in, which often shifts width, jumps the baseline, or leaves an awkward blank beat in the middle.
HoverSwapText keeps the resting width locked. At rest you see natural text. On hover, each character slides up inside a clipped slot, like a tiny mechanical flap.
The trick is an invisible copy of the from string sitting in the same grid cell. That reserves space so the line never reflows when the animation starts.
Word-level alignment
Before animating, the component splits from and to into words and pads each word pair independently. That keeps spaces between words aligned even when the strings differ in length.
Building interfaces and Breaking Interfaces stay in sync because Building lines up with Breaking, and interfaces with Interfaces. No letter in one string gets paired with a space from the other mid-word.
When word counts or word lengths differ, extra characters are padded with \u00A0 at the end of the shorter word only. After the stagger finishes, the component settles on plain to text with natural kerning.
Here is the header line from this site, embedded directly in prose:
I am
Building interfacesBuilding interfacesBuilding interfacesmost days.
Inline demo: mismatched lengths
And here is the Now section punchline:
One of those, usually.One of those, usually.One of those, usually.How the animation works
Each character slot is a one-line-tall window with overflow hidden. Inside, two lines are stacked vertically: the from character on top, the to character below. On hover, Framer Motion animates y from 0 to -1lh, which pulls the bottom character into view.
A stagger delays each character so letters do not all move at once. On hover, the ripple runs forward through the string. On mouse leave, it runs backward. The default stagger is 0.028 seconds per character.
During the transition you see the stagger. Once it finishes, plain to text takes over so kerning stays natural while hovered.
Accessibility
Hover-only effects are decorative, so the component does not rely on them for meaning.
aria-labelon the interactive span describes both strings.- Screen reader text (
sr-only) mirrors whichever version is visible. prefers-reduced-motionfalls back to an instant text swap with no animation.
If someone cannot hover, they still get the alternate copy. If they prefer reduced motion, they get it without the slide.
Usage
import { HoverSwapText } from "@/components/hover-swap-text";
<HoverSwapText
from="Building interfaces"
to="Breaking Interfaces"
className="serif-italic"
stagger={0.028}
/>
Where it shows up on this site
- The intro line in the header (
Building interfaces→Breaking Interfaces) - The closing line in the Now section (
One of those, usually.→Sometimes all three.) - The 404 page (
Page not found→Wrong turn?)
Same component, same rules. Content authors pass two strings; the component handles layout, mode selection, motion, and accessibility.
Closing thought
Small UI details like this are easy to overbuild. The useful version is narrow: inline text, two strings, no layout shift, motion that respects preferences. Everything else is implementation detail hiding behind a single hover.