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.

  • JS
  • Text
  • Loop
  • Added
LONDON

AI prompt

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

Demo.tsx
'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>
  )
}
demo.module.css
.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; }
}

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.