Split Flap
Each tile of a departure board flips over in turn to the next word, with a short stagger so the change ripples across.
Create a split-flap board in React and CSS that cycles through words. Pad every word to the length of the longest with spaces and render one tile per character (monospace, dark surface, a 1px horizontal seam through the middle via a pseudo-element). Every 2.4s, switch to the next word. Give each tile the key `${index}-${char}` so React remounts only tiles whose character changed, and on mount play a CSS keyframe: rotateX(-90deg) with opacity 0.4 to rotateX(0) over 360ms with cubic-bezier(0.16, 1, 0.3, 1), transform-origin center, inside a parent with perspective 400px. Delay each tile by index × 60ms with an inline animation-delay. Expose the current word through aria-live="polite" on a visually hidden span and mark the tiles aria-hidden. Pause the interval when off screen, and under prefers-reduced-motion swap words without the flip. The words are LONDON, TOKYO, LISBON, SEOUL.
Source
'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; }
}