Rotating Words
One word in a headline slides up and out while the next slides in from below, and the slot resizes to fit it.
Design that feels calm
Build a React headline where the last word rotates: "Design that feels" followed by calm, precise, alive and yours. Every 2.2s advance an index. Render the words stacked in an inline-grid cell with overflow hidden; the active word sits at translateY(0) and opacity 1, the previous word moves to translateY(-100%) and opacity 0, and the rest wait at translateY(100%). Transition transform and opacity over 500ms with cubic-bezier(0.16, 1, 0.3, 1). Animate the slot width to the active word: measure it with a hidden copy and set width with a 300ms transition. Colour the rotating word with the accent. Keep the full sentence with the current word in an aria-live="polite" visually hidden element. Under prefers-reduced-motion, swap words with a 150ms fade.
Source
'use client'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import s from './demo.module.css'
export default function RotatingWords({ labels }: { labels: Record<string, string> }) {
const words = labels.words.split('|')
const [index, setIndex] = useState(0)
const [width, setWidth] = useState<number>()
const measure = useRef<HTMLSpanElement>(null)
useEffect(() => {
const id = setInterval(() => setIndex((i) => (i + 1) % words.length), 2200)
return () => clearInterval(id)
}, [words.length])
useLayoutEffect(() => {
if (measure.current) setWidth(measure.current.offsetWidth)
}, [index, labels.words])
const prev = (index - 1 + words.length) % words.length
return (
<p className={s.line}>
<span aria-hidden="true">{labels.lead} </span>
<span className={s.slot} style={{ width }} aria-hidden="true">
{words.map((w, i) => (
<span key={w} className={s.word} data-state={i === index ? 'in' : i === prev ? 'out' : 'wait'}>
{w}
</span>
))}
</span>
<span ref={measure} className={s.measure} aria-hidden="true">
{words[index]}
</span>
<span className={s.sr} aria-live="polite">
{labels.lead} {words[index]}
</span>
</p>
)
}
.line {
position: relative;
display: flex;
align-items: baseline;
gap: 0.25em;
margin: 0;
font: 600 var(--text-2xl) / 1.2 var(--font-display);
letter-spacing: -0.02em;
color: var(--stage-ink);
white-space: nowrap;
}
.slot {
display: inline-grid;
overflow: hidden;
transition: width 300ms var(--ease-out);
}
.word {
grid-area: 1 / 1;
color: var(--stage-accent);
transition: transform 500ms var(--ease-out), opacity 500ms var(--ease-out);
}
.word[data-state='in'] { transform: translateY(0); opacity: 1; }
.word[data-state='out'] { transform: translateY(-100%); opacity: 0; }
.word[data-state='wait'] { transform: translateY(100%); opacity: 0; transition: none; }
.measure { position: absolute; visibility: hidden; white-space: nowrap; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
@media (prefers-reduced-motion: reduce) {
.word, .word[data-state] { transform: none; transition: opacity 150ms linear; }
.slot { transition: none; }
}