Rolling Text Button
On hover each letter of the label rolls up and is replaced by a copy from below, in a quick wave from left to right.
Create a button whose label rolls letter by letter on hover, in pure CSS with a React split. Split the label into one span per character (keep spaces as non-breaking spaces) and give each span text-shadow: 0 1.3em 0 currentColor, which draws an identical copy one line below. Wrap the letters in an inline-flex span with height 1.3em and overflow: hidden so only one line shows, and give each letter a line-height of 1.3em. On :hover and :focus-visible, translate every letter to translateY(-1.3em) over 400ms with cubic-bezier(0.16, 1, 0.3, 1), and set transition-delay to index × 25ms via a CSS variable so the roll sweeps left to right. Put the full label in aria-label and mark the letter spans aria-hidden. Under prefers-reduced-motion, keep the label still and change only the background. The label reads "Get in touch".
Source
'use client'
import s from './demo.module.css'
export default function RollingText({ labels }: { labels: Record<string, string> }) {
return (
<button type="button" className={s.button} aria-label={labels.cta}>
<span className={s.label}>
{Array.from(labels.cta).map((c, i) => (
<span key={i} className={s.char} style={{ '--i': i } as React.CSSProperties} aria-hidden="true">
{c === ' ' ? ' ' : c}
</span>
))}
</span>
</button>
)
}
.button {
display: inline-flex;
align-items: center;
height: 52px;
padding: 0 30px;
border: 0;
border-radius: 999px;
background: var(--stage-ink);
color: var(--stage-bg);
font: 600 var(--text-base) / 1.3 var(--font-body);
white-space: nowrap;
cursor: pointer;
transition: background-color var(--dur-short);
}
.button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 3px; }
/* clip to exactly one line so the shadow copy below stays hidden */
.label {
display: inline-flex;
height: 1.3em;
overflow: hidden;
}
.char {
display: inline-block;
line-height: 1.3em;
text-shadow: 0 1.3em 0 currentColor;
transition: transform 400ms var(--ease-out);
transition-delay: calc(var(--i) * 25ms);
}
.button:hover .char,
.button:focus-visible .char { transform: translateY(-1.3em); }
@media (prefers-reduced-motion: reduce) {
.char { transition: none; }
.button:hover .char, .button:focus-visible .char { transform: none; }
.button:hover { background: var(--stage-accent); }
}