| name | flipoff-split-flap-display |
| description | Expert skill for building, customizing, and embedding the FlipOff split-flap display emulator — a free, offline-capable web app that turns any browser/TV into a retro airport departure board. |
| triggers | ["add split flap display to my site","customize flipoff messages or quotes","embed retro flip board display","change flipoff grid size or colors","how do I use flipoff split flap emulator","add keyboard controls to flipoff","flipoff sound and animation configuration","turn TV into flip board display"] |
FlipOff Split-Flap Display Emulator
Skill by ara.so — Daily 2026 Skills collection.
FlipOff is a pure vanilla HTML/CSS/JS web app that emulates classic mechanical split-flap (flip-board) airport displays. No frameworks, no npm, no build step — open index.html and you have a full-screen retro display with authentic scramble animations and clacking sounds.
Installation
git clone https://github.com/magnum6actual/flipoff.git
cd flipoff
open index.html
python3 -m http.server 8080
Audio note: Browsers block autoplay. The user must click once to enable the Web Audio API context. After that, sound plays automatically on each message transition.
File Structure
flipoff/
index.html — Single-page app entry point
css/
reset.css — CSS reset
layout.css — Header, hero, page layout
board.css — Board container and accent bars
tile.css — Tile styling and 3D flip animation
responsive.css — Media queries (mobile → 4K)
js/
main.js — Entry point, wires everything together
Board.js — Grid manager, transition orchestration
Tile.js — Per-tile animation logic
SoundEngine.js — Web Audio API playback
MessageRotator.js — Auto-rotate timer
KeyboardController.js — Keyboard shortcut handling
constants.js — All configuration lives here
flapAudio.js — Base64-encoded audio data
Key Configuration — js/constants.js
Everything you'd want to change lives in one file:
export const GRID_COLS = 26;
export const GRID_ROWS = 8;
export const SCRAMBLE_DURATION = 600;
export const STAGGER_DELAY = 18;
export const AUTO_ROTATE_INTERVAL = 8000;
export const SCRAMBLE_COLORS = [
'#FF6B35', '#F7C59F', '#EFEFD0',
'#004E89', '#1A936F', '#C6E0F5'
];
export const ACCENT_COLORS = ['#FF6B35', '#004E89', '#1A936F'];
export const MESSAGES = [
"HAVE A NICE DAY",
"ALL FLIGHTS ON TIME",
"WELCOME TO THE FUTURE",
];
Adding Custom Messages
Edit MESSAGES in js/constants.js. Each message is a plain string. The board wraps text across the grid automatically.
export const MESSAGES = [
"DEPARTING GATE 7",
"YOUR COFFEE IS READY",
"BUILD THINGS THAT MATTER",
"FLIGHT AA 404 NOT FOUND",
];
Padding rules: Messages shorter than the grid are padded with spaces. Messages longer than the grid are truncated. Keep messages at or under GRID_COLS × GRID_ROWS characters.
Changing Grid Size
export const GRID_COLS = 16;
export const GRID_ROWS = 4;
export const GRID_COLS = 40;
export const GRID_ROWS = 6;
export const GRID_COLS = 20;
export const GRID_ROWS = 12;
After changing grid dimensions, tiles re-render automatically on next page load.
Keyboard Shortcuts
| Key | Action |
|---|
Enter / Space | Next message |
Arrow Left | Previous message |
Arrow Right | Next message |
F | Toggle fullscreen |
M | Toggle mute |
Escape | Exit fullscreen |
Programmatic API
Board
import Board from './js/Board.js';
const board = new Board(document.getElementById('board-container'));
board.setMessage('GATE CHANGE B12');
board.next();
board.previous();
MessageRotator
import MessageRotator from './js/MessageRotator.js';
const rotator = new MessageRotator(board, messages, AUTO_ROTATE_INTERVAL);
rotator.start();
rotator.stop();
rotator.next();
rotator.previous();
SoundEngine
import SoundEngine from './js/SoundEngine.js';
const sound = new SoundEngine();
await sound.init();
sound.play();
sound.mute();
sound.unmute();
sound.toggle();
KeyboardController
import KeyboardController from './js/KeyboardController.js';
const kb = new KeyboardController({
onNext: () => rotator.next(),
onPrevious: () => rotator.previous(),
onFullscreen: () => toggleFullscreen(),
onMute: () => sound.toggle(),
});
kb.attach();
kb.detach();
Embedding FlipOff in Another Page
As an iframe
<iframe
src="/flipoff/index.html"
width="1280"
height="400"
style="border:none; background:#000;"
allowfullscreen
></iframe>
Inline embed (pull in just the board)
<div id="flip-board"></div>
<script type="module">
import Board from '/flipoff/js/Board.js';
import SoundEngine from '/flipoff/js/SoundEngine.js';
import { MESSAGES, AUTO_ROTATE_INTERVAL } from '/flipoff/js/constants.js';
const board = new Board(document.getElementById('flip-board'));
const sound = new SoundEngine();
let idx = 0;
board.setMessage(MESSAGES[idx]);
document.addEventListener('click', async () => {
await sound.init();
}, { once: true });
setInterval(() => {
idx = (idx + 1) % MESSAGES.length;
board.setMessage(MESSAGES[idx]);
sound.();
}, );
Custom Color Themes
export const SCRAMBLE_COLORS = [
'#0D1B2A', '#1B2838', '#00FF41',
'#003459', '#007EA7', '#00A8E8'
];
export const ACCENT_COLORS = ['#00FF41', '#007EA7', '#00A8E8'];
.tile {
background-color: #0D1B2A;
color: #00FF41;
border-color: #003459;
}
Common Patterns
Show real-time data (e.g., a flight API)
import Board from './js/Board.js';
import SoundEngine from './js/SoundEngine.js';
const board = new Board(document.getElementById('board'));
const sound = new SoundEngine();
async function fetchAndDisplay() {
const res = await fetch('/api/departures');
const data = await res.json();
const message = `${data.flight} ${data.destination} ${data.gate}`;
board.setMessage(message.toUpperCase());
sound.play();
}
document.addEventListener('click', () => sound.init(), { once: true });
setInterval(fetchAndDisplay, 30_000);
fetchAndDisplay();
Cycle through a custom message list
const promos = [
"SALE ENDS SUNDAY",
"FREE SHIPPING OVER $50",
"NEW ARRIVALS THIS WEEK",
];
let i = 0;
setInterval(() => {
board.setMessage(promos[i % promos.length]);
sound.play();
i++;
}, 5000);
React/Vue wrapper (import as a module)
import { useEffect, useRef } from 'react';
import Board from '../flipoff/js/Board.js';
import { MESSAGES } from '../flipoff/js/constants.js';
export default function FlipBoard({ messages = MESSAGES, interval = 8000 }) {
const containerRef = useRef(null);
const boardRef = useRef(null);
useEffect(() => {
boardRef.current = new Board(containerRef.current);
let idx = 0;
boardRef.current.setMessage(messages[idx]);
const timer = setInterval(() => {
idx = (idx + 1) % messages.length;
boardRef.current.setMessage(messages[idx]);
}, interval);
return () => clearInterval(timer);
}, []);
return <div ref={containerRef} className= />;
}
Troubleshooting
| Problem | Fix |
|---|
| No sound | User must click/interact first; Web Audio requires a user gesture |
| Sound works locally but not deployed | Ensure flapAudio.js (base64) is served; check MIME types |
| Tiles don't animate | Verify CSS tile.css is loaded; check for JS console errors |
| Grid overflows on small screens | Reduce GRID_COLS/GRID_ROWS in constants.js or add CSS overflow: hidden |
| Fullscreen not working | F key calls requestFullscreen() — some browsers require the page to be focused |
| Messages cut off | String length exceeds GRID_COLS × GRID_ROWS; shorten or increase grid size |
| Audio blocked by CSP | Add media-src 'self' blob: data: to your Content-Security-Policy |
| CORS error loading modules | Serve with a local server (python3 -m http.server), not file:// |
Tips for TV / Kiosk Deployment
npx serve .
python3 -m http.server
chromium-browser --kiosk --app=http://localhost:8080
document.addEventListener('mousemove', () => {
document.body.style.cursor = 'default';
clearTimeout(window._cursorTimer);
window._cursorTimer = setTimeout(() => {
document.body.style.cursor = 'none';
}, 3000);
});