翻牌显示
像机场的出发信息牌,每一块翻牌依次翻到下一个词,带一点错开,让变化像波浪一样掠过。
北京
用 React 和 CSS 制作循环切换词语的翻牌显示板。把每个词用空格补到最长词的长度,每个字符渲染为一块翻牌(等宽字体、深色底、用伪元素在中间画一条 1px 的横向缝)。每 2.4s 切换到下一个词。给每块翻牌设置 key 为 `${index}-${char}`,这样 React 只会重新挂载字符有变化的翻牌;挂载时播放 CSS keyframe:从 rotateX(-90deg)、opacity 0.4 到 rotateX(0),时长 360ms,缓动 cubic-bezier(0.16, 1, 0.3, 1),transform-origin 居中,父元素设置 perspective 400px。每块翻牌通过内联 animation-delay 延迟 index × 60ms。用一个视觉隐藏的 span 配合 aria-live="polite" 播报当前词,翻牌本身设为 aria-hidden。离开屏幕时暂停计时器,在 prefers-reduced-motion 下只切换文字不做翻转。词语为北京、上海、成都、广州。
源代码
'use client'
import { useEffect, useRef, useState } from 'react'
import s from './demo.module.css'
const INTERVAL = 2400
export default function SplitFlap({ labels }: { labels: Record<string, string> }) {
const words = labels.words.split('|')
const width = Math.max(...words.map((w) => Array.from(w).length))
const [i, setI] = useState(0)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
let timer = 0
const io = new IntersectionObserver(([e]) => {
clearInterval(timer)
if (e.isIntersecting) timer = window.setInterval(() => setI((n) => (n + 1) % words.length), INTERVAL)
})
io.observe(el)
return () => {
io.disconnect()
clearInterval(timer)
}
}, [words.length])
const chars = Array.from(words[i].padEnd(width, ' '))
return (
<div ref={ref} className={s.board}>
<span className={s.sr} aria-live="polite">
{words[i]}
</span>
{chars.map((c, n) => (
<span key={`${n}-${c}`} className={s.tile} style={{ animationDelay: `${n * 60}ms` }} aria-hidden="true">
{c}
</span>
))}
</div>
)
}
.board {
display: flex;
gap: 4px;
perspective: 400px;
}
.tile {
position: relative;
display: grid;
place-items: center;
width: 36px;
height: 52px;
border-radius: 6px;
background: var(--stage-surface);
box-shadow: inset 0 0 0 1px var(--stage-rule);
color: var(--stage-ink);
font: 600 var(--text-2xl) / 1 var(--font-mono);
white-space: pre;
animation: flip 360ms var(--ease-out) both;
}
.tile::after {
content: '';
position: absolute;
inset: 50% 0 auto;
height: 1px;
background: var(--stage-bg);
}
@keyframes flip {
from { transform: rotateX(-90deg); opacity: 0.4; }
to { transform: rotateX(0); opacity: 1; }
}
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (max-width: 360px) {
.tile { width: 30px; height: 44px; font-size: var(--text-xl); }
}
@media (prefers-reduced-motion: reduce) {
.tile { animation: none; }
}