轮播词
标题里的一个词向上滑出,下一个词从下方滑入,词槽宽度随之调整。
让设计感觉 安静
用 React 制作最后一个词会轮换的标题:“让设计感觉”后面依次显示安静、精准、鲜活、属于你。每 2.2 秒切换一次序号。把所有词叠放在一个 overflow hidden 的 inline-grid 单元格中;当前词位于 translateY(0)、opacity 1,上一个词移动到 translateY(-100%)、opacity 0,其余词在 translateY(100%) 等待。transform 和 opacity 的过渡时间为 500ms,缓动为 cubic-bezier(0.16, 1, 0.3, 1)。让词槽宽度跟随当前词变化:用一个隐藏副本测量宽度,并以 300ms 过渡设置 width。轮换的词使用强调色。用一个视觉隐藏、带 aria-live="polite" 的元素保存包含当前词的完整句子。在 prefers-reduced-motion 下改为 150ms 的淡入淡出切换。
源代码
'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; }
}