Split Flap
Good things are on their way. A mechanical departure board with hinged letters and a cascading flip.
DEPARTURESOPEN TICKETS
'use client';
import { useEffect, useState, type CSSProperties } from 'react';
import './split-flap.css';
export interface SplitFlapProps {
text: string;
className?: string;
/** An optional description placed before the complete accessible text. */
label?: string;
/** The duration of one mechanical flip, in milliseconds. */
duration?: number;
/** The delay between tiles, in milliseconds. */
stagger?: number;
}
/** A departure-board display that flips only when its supplied text changes. */
export function SplitFlap({
text,
className = '',
label,
duration = 500,
stagger = 35,
}: SplitFlapProps) {
const [display, setDisplay] = useState({ text, previous: text, revision: 0 });
const [settledRevision, setSettledRevision] = useState(0);
const flipDuration = Number.isFinite(duration) ? Math.min(1500, Math.max(150, duration)) : 500;
const tileStagger = Number.isFinite(stagger) ? Math.min(150, Math.max(0, stagger)) : 35;
// Adjusting this component's own render state keeps a new phrase and its old
// tile faces together, without first painting the new text and then jumping back.
if (display.text !== text) {
setDisplay({ text, previous: display.text, revision: display.revision + 1 });
}
const characters = Array.from(display.text);
const previousCharacters = Array.from(display.previous);
const tileCount = characters.filter((character) => character !== '\n' && character !== '\r').length;
const lastDelay = Math.min(1500, Math.max(0, tileCount - 1) * tileStagger);
const isFlipping = display.revision > settledRevision;
useEffect(() => {
if (display.revision === 0) return;
const revision = display.revision;
const preference = window.matchMedia('(prefers-reduced-motion: reduce)');
let timer: ReturnType<typeof setTimeout> | undefined;
const settle = () => {
if (timer !== undefined) clearTimeout(timer);
setSettledRevision(revision);
};
const handlePreference = () => {
if (preference.matches) settle();
};
if (preference.matches) settle();
else timer = setTimeout(settle, flipDuration + lastDelay + 50);
preference.addEventListener('change', handlePreference);
return () => {
if (timer !== undefined) clearTimeout(timer);
preference.removeEventListener('change', handlePreference);
};
}, [display.revision, flipDuration, lastDelay]);
let tileIndex = 0;
const style = { '--ac-flap-duration': `${flipDuration}ms` } as CSSProperties;
return (
<div
className={`ac-flap ${className}`.trim()}
role="img"
aria-label={label ? `${label}: ${text}` : text}
style={style}
>
<div className="ac-flap__tiles" aria-hidden="true">
{characters.map((character, index) => {
if (character === '\r') return null;
if (character === '\n') return <span className="ac-flap__break" key={`break-${index}`} />;
const previous = previousCharacters[index];
const oldCharacter = previous && previous !== '\n' && previous !== '\r' ? previous : ' ';
const changing = isFlipping && oldCharacter !== character;
const tileStyle = { '--ac-flap-delay': `${Math.min(1500, tileIndex++ * tileStagger)}ms` } as CSSProperties;
return (
<span className="ac-flap__tile" key={`${display.revision}-${index}`} style={tileStyle}>
<span className="ac-flap__letter">{character === ' ' ? '\u00a0' : character}</span>
{changing && <>
<span className="ac-flap__half ac-flap__old-bottom"><span>{oldCharacter === ' ' ? '\u00a0' : oldCharacter}</span></span>
<span className="ac-flap__half ac-flap__old-top"><span>{oldCharacter === ' ' ? '\u00a0' : oldCharacter}</span></span>
<span className="ac-flap__half ac-flap__new-bottom"><span>{character === ' ' ? '\u00a0' : character}</span></span>
</>}
<span className="ac-flap__seam" />
</span>
);
})}
</div>
</div>
);
}
export default SplitFlap;
Installation
Run this in your shadcn-configured React project. The component and stylesheet are added together.
npx shadcn@latest add http://localhost:5173/r/split-flap.jsonThis command uses the current site address. For localhost, keep this preview running on the same computer. Files follow your configured components alias.
Usage
Import the component and make the details your own.
'use client';
import { useState } from 'react';
import { SplitFlap } from '@/components/anticraft/SplitFlap';
export function Example() {
const [text, setText] = useState('STAY\nCURIOUS');
return (
<div style={{ maxWidth: 360, padding: 24, background: '#e8dc9c' }}>
<SplitFlap text={text} label="Next departure" duration={500} stagger={35} />
<button type="button" onClick={() => setText(text === 'STAY\nCURIOUS' ? 'MAKE\nWAVES' : 'STAY\nCURIOUS')}>
Change departure
</button>
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | Required | The complete phrase. Spaces, punctuation, Unicode code points, and newline characters are preserved. Long phrases wrap instead of being truncated. |
label | string | undefined | An optional description placed before the complete phrase in the accessible image name. Decorative tile faces are hidden from assistive technology. |
duration | number | 500 | The duration of an individual flip in milliseconds, clamped from 150 to 1500. |
stagger | number | 35 | The delay between tiles in milliseconds, clamped from 0 to 150. Cascading delays stop growing after 1500ms so long phrases finish promptly. |
className | string | '' | Additional classes. Customize --ac-flap-surface, --ac-flap-color, --ac-flap-font, --ac-flap-tile-width, --ac-flap-tile-height, and --ac-flap-gap. |
Motion & accessibility
The first render contains the actual text. Changed letters flip in a short mechanical cascade, and another change safely replaces the current animation. There is no automatic cycling. Reduced motion shows each new phrase immediately. Supply changing text from a Client Component; the example includes that boundary. The component exposes one complete accessible phrase without an automatic live announcement; add a separate status message when the surrounding interaction needs one.
Use semantic content and meaningful labels in your composition. Check contrast and keyboard behavior with your own styles and content.