Flapboard
import { useEffect, useState } from 'react';
const css = `
@keyframes field-flapboard-flap { from { transform: rotateX(80deg); filter: brightness(1.6); } }
.field-flapboard { width: 250px; }
.field-flapboard__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-flapboard__input {
width: 100%;
height: 38px;
padding: 0 12px;
border: 0;
outline: 0;
border-radius: 9px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit; font-size: 14px; font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
transition: box-shadow 0.2s;
}
.field-flapboard__input:focus { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-flapboard__board { display: flex; gap: 2px; margin-top: 10px; padding: 6px; border-radius: 8px; background: light-dark(#fbfcfd, #121417); box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529); }
.field-flapboard__cell {
position: relative;
flex: 1;
height: 28px;
border-radius: 3px;
background: light-dark(#ebedf0, #232529);
color: light-dark(#373a40, #f1f2f3);
font: 600 16px/28px ui-monospace, SFMono-Regular, Menlo, monospace;
text-align: center;
perspective: 80px;
overflow: hidden;
}
/* The split across the middle of every flap. */
.field-flapboard__cell::after { content: ''; position: absolute; left: 0; right: 0; top: 13.5px; height: 1px; background: light-dark(#fbfcfd, #121417); }
.field-flapboard__ch { display: block; transform-origin: 50% 50%; animation: field-flapboard-flap 0.09s ease-out; }
.field-flapboard__meta { display: flex; justify-content: space-between; margin-top: 6px; color: light-dark(#5f626a, #a7abb2); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; }
@media (prefers-reduced-motion: reduce) { .field-flapboard__ch { animation: none; } }
`;
const ALPHA = ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-.';
const CELLS = 12;
const pad = (s: string) => s.toUpperCase().replace(/[^A-Z0-9 .-]/g, ' ').padEnd(CELLS, ' ').slice(0, CELLS);
export function FlapboardInput() {
const [value, setValue] = useState('Lisbon');
const [shown, setShown] = useState(() => pad(''));
// Every tick, each cell that isn't there yet flips one letter further round the drum.
useEffect(() => {
const target = pad(value);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return setShown(target);
const id = window.setInterval(() => {
setShown((cur) => {
if (cur === target) return cur;
return [...cur]
.map((c, i) => (c === target[i] ? c : ALPHA[(ALPHA.indexOf(c) + 1) % ALPHA.length]))
.join('');
});
}, 45);
return () => clearInterval(id);
}, [value]);
return (
<>
<style>
{css}
</style>
<div className="field-flapboard">
<label className="field-flapboard__label" htmlFor="field-flapboard-input">
Destination
</label>
<input
id="field-flapboard-input"
className="field-flapboard__input"
value={value}
maxLength={CELLS}
autoComplete="off"
spellCheck={false}
onChange={(e) => setValue(e.target.value)}
/>
<div className="field-flapboard__board" aria-hidden="true">
{[...shown].map((c, i) => (
<span key={i} className="field-flapboard__cell">
<span key={c} className="field-flapboard__ch">
{c === ' ' ? ' ' : c}
</span>
</span>
))}
</div>
<div className="field-flapboard__meta" aria-hidden="true">
<span>Gate 14</span>
<span>{shown === pad(value) ? 'On time' : 'Updating'}</span>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldFlapboard(el) on the mounted element instead.
<div class="field-flapboard">
<label class="field-flapboard__label" for="field-flapboard-input">Destination</label>
<input id="field-flapboard-input" class="field-flapboard__input" maxLength="12" autoComplete="off" spellCheck="false" value="Lisbon"/>
<div class="field-flapboard__board" aria-hidden="true">
<span class="field-flapboard__cell">
<span class="field-flapboard__ch"> </span>
</span>
<span class="field-flapboard__cell">
<span class="field-flapboard__ch"> </span>
</span>
<span class="field-flapboard__cell">
<span class="field-flapboard__ch"> </span>
</span>
<span class="field-flapboard__cell">
<span class="field-flapboard__ch"> </span>
</span>
<span class="field-flapboard__cell">
<span class="field-flapboard__ch"> </span>
</span>
<span class="field-flapboard__cell">
<span class="field-flapboard__ch"> </span>
</span>
<span class="field-flapboard__cell">
<span class="field-flapboard__ch"> </span>
</span>
<span class="field-flapboard__cell">
<span class="field-flapboard__ch"> </span>
</span>
<span class="field-flapboard__cell">
<span class="field-flapboard__ch"> </span>
</span>
<span class="field-flapboard__cell">
<span class="field-flapboard__ch"> </span>
</span>
<span class="field-flapboard__cell">
<span class="field-flapboard__ch"> </span>
</span>
<span class="field-flapboard__cell">
<span class="field-flapboard__ch"> </span>
</span>
</div>
<div class="field-flapboard__meta" aria-hidden="true">
<span>Gate 14</span>
<span>Updating</span>
</div>
</div>@keyframes field-flapboard-flap { from { transform: rotateX(80deg); filter: brightness(1.6); } }
.field-flapboard { width: 250px; }
.field-flapboard__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-flapboard__input {
width: 100%;
height: 38px;
padding: 0 12px;
border: 0;
outline: 0;
border-radius: 9px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit; font-size: 14px; font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
transition: box-shadow 0.2s;
}
.field-flapboard__input:focus { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-flapboard__board { display: flex; gap: 2px; margin-top: 10px; padding: 6px; border-radius: 8px; background: light-dark(#fbfcfd, #121417); box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529); }
.field-flapboard__cell {
position: relative;
flex: 1;
height: 28px;
border-radius: 3px;
background: light-dark(#ebedf0, #232529);
color: light-dark(#373a40, #f1f2f3);
font: 600 16px/28px ui-monospace, SFMono-Regular, Menlo, monospace;
text-align: center;
perspective: 80px;
overflow: hidden;
}
/* The split across the middle of every flap. */
.field-flapboard__cell::after { content: ''; position: absolute; left: 0; right: 0; top: 13.5px; height: 1px; background: light-dark(#fbfcfd, #121417); }
.field-flapboard__ch { display: block; transform-origin: 50% 50%; animation: field-flapboard-flap 0.09s ease-out; }
.field-flapboard__meta { display: flex; justify-content: space-between; margin-top: 6px; color: light-dark(#5f626a, #a7abb2); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; }
@media (prefers-reduced-motion: reduce) { .field-flapboard__ch { animation: none; } }function initFieldFlapboard(root) {
var input = root.querySelector('.field-flapboard__input');
var cells = root.querySelectorAll('.field-flapboard__cell');
var status = root.querySelector('.field-flapboard__meta span:last-child');
if (!input || !cells.length || !status) return;
var ALPHA = ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-.';
var N = cells.length;
function pad(s) { return s.toUpperCase().replace(/[^A-Z0-9 .-]/g, ' ').padEnd(N, ' ').slice(0, N); }
var shown = Array.prototype.map.call(cells, function (c) { return c.textContent === ' ' ? ' ' : c.textContent || ' '; }).join('');
var timer = 0;
function paint(cur, prev) {
for (var i = 0; i < N; i++) {
if (cur[i] === prev[i]) continue;
// A new element per change, so the flap replays.
var ch = document.createElement('span');
ch.className = 'field-flapboard__ch';
ch.textContent = cur[i] === ' ' ? ' ' : cur[i];
cells[i].textContent = '';
cells[i].appendChild(ch);
}
}
// Every tick, each cell that isn't there yet flips one letter further round the drum.
function run() {
var target = pad(input.value);
clearInterval(timer);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
paint(target, shown);
shown = target;
status.textContent = 'On time';
return;
}
status.textContent = shown === target ? 'On time' : 'Updating';
timer = setInterval(function () {
if (shown === target) {
clearInterval(timer);
status.textContent = 'On time';
return;
}
var next = shown.split('').map(function (c, i) { return c === target[i] ? c : ALPHA[(ALPHA.indexOf(c) + 1) % ALPHA.length]; }).join('');
paint(next, shown);
shown = next;
}, 45);
}
input.addEventListener('input', run);
run();
return function () {
clearInterval(timer);
input.removeEventListener('input', run);
};
}
document.querySelectorAll('.field-flapboard').forEach(initFieldFlapboard);Goal: Create a "Flapboard" input.
Visual behavior: Type a destination and a split-flap departure board rolls every cell through the alphabet to the new letters, one flap at a time, with the status reading Updating until the board settles.
Technique:
- The board is twelve cells; the target is the input uppercased, cleaned to the board's characters and padded to twelve.
- Every 45ms each cell that is not yet on its target advances one character round the drum (space, A to Z, 0 to 9, - and .), so cells further away take longer, like a real board.
- Each change re-creates the cell's character so it replays a short flap keyframe (rotateX from 80deg); a hairline across the middle is the split.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The board is aria-hidden; the field holds the value. Under prefers-reduced-motion the board changes at once.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Flapboard" input.
Visual behavior: Type a destination and a split-flap departure board rolls every cell through the alphabet to the new letters, one flap at a time, with the status reading Updating until the board settles.
Technique:
- The board is twelve cells; the target is the input uppercased, cleaned to the board's characters and padded to twelve.
- Every 45ms each cell that is not yet on its target advances one character round the drum (space, A to Z, 0 to 9, - and .), so cells further away take longer, like a real board.
- Each change re-creates the cell's character so it replays a short flap keyframe (rotateX from 80deg); a hairline across the middle is the split.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The board is aria-hidden; the field holds the value. Under prefers-reduced-motion the board changes at once.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
Type a destination and a split-flap departure board rolls every cell through the alphabet to the new letters, one flap at a time, with the status reading Updating until the board settles.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, continuous
- Techniques
- interval, keyframes, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <input> with a visible <label>; decoration is aria-hidden. The board is aria-hidden; the field holds the value. Under prefers-reduced-motion the board changes at once.