Turn any TV into a retro split-flap display. The classic flip-board look, without the $3,500 hardware. And it's free.
FlipOff is a free, open-source web app that emulates a classic mechanical split-flap (flip-board) airport terminal display — the kind you'd see at train stations and airports. It runs full-screen in any browser, turning a TV or large monitor into a beautiful retro display.
No accounts. No subscriptions. No $199 fee. Build it once and go.
- Realistic split-flap animation with mechanical character-stepping (top/bottom flap halves)
- Multiple sound modes: Authentic (default), Soft, Joke (rubber duck + fart), Mute — with stereo panning per tile
- Auto-rotating screens with shuffle and random mode
- Admin dashboard for board geometry, screens, plugins and message overrides — reachable from any device on the network
- Plugin screens: clock, current weather, 3-day forecast, GitHub stats, Quote of the Day, crypto prices
- Real-time WebSocket push — message and rotation changes appear on every connected display instantly
- Connection indicator, and automatic reconnection: a display survives a server restart without anyone touching it
- Multi-board support — run multiple independent displays from one server
- Display modes: Color, Matrix, Grayscale accent palettes
- Loading line across the top of the screen showing time until the next message
- Tiles sized to the viewport, so any grid the server is configured with fits at any width
- Navbar hides itself until you hover it, so the board is chrome-free at rest
- Fullscreen TV mode
- Keyboard controls for manual navigation
- Emoji support in messages (animate through random charset characters before landing)
- Responsive from mobile to 4K displays
- Vanilla HTML/CSS/JS frontend — no UI framework, just Vite for bundling
The display is a client of the backend — it reads its grid, charset, colours, timing and
its entire rotation from the server's /api/config. board/dist is not deployable on
its own; there is no static/no-server mode.
git clone https://github.com/<your-username>/flipoff.git
cd flipoff
# With Docker (recommended):
docker compose up --build
# Or without Docker (needs Node 24+, for node:sqlite):
pnpm install
pnpm build
pnpm -C backend startThe backend serves board/dist and admin/dist, so both frontends have to be built
before they will show anything. For development, pnpm dev starts all three processes:
the admin on http://localhost:5173, the board on 5174, and the backend on 8080. Run them
together — a Vite server alone has no backend to read its config from. The admin's dev
server is the front door: it proxies /api and /ws to the backend and /b to the
board's, so http://localhost:5173 behaves exactly like the deployed app.
| URL | What |
|---|---|
http://localhost:8080 |
Admin dashboard (network-wide, password-protected) |
http://localhost:8080/boards |
Straight to a dashboard page — every admin page has its own URL |
http://localhost:8080/b |
Public index of every board, linking to each one |
http://localhost:8080/b/<board-slug> |
A board. main is the one a fresh install creates |
The admin password is auto-generated on first run and printed to the console. Set it
explicitly with the ADMIN_PASSWORD environment variable.
Boards and screens persist in a Docker volume (flipoff-data), and are edited in the
admin. The seed in backend/src/config/seed.ts only ever applies to a board being
created — changing it does nothing to an install that already has one.
A board's rotation is a list of screens, managed in the admin dashboard. Two kinds:
- Manual — fixed lines you type in. A new board seeds these from the
messagesarray inbackend/src/config/seed.ts. - Plugin — lines regenerated on a refresh interval.
| Plugin | Shows | Needs |
|---|---|---|
| Date & Time | Time, weekday, date, UTC offset | A time zone |
| Open-Meteo Current Weather | City, country flag, temperature, condition | A city + country |
| Open-Meteo 3 Day Forecast | Weekday, min/max temperature, condition | A city + country |
| GitHub Repo Stats | Stars, forks, watchers | A repo |
| GitHub Open Work | Open issues and pull requests | A repo |
| Quote of the Day | A daily quote | API_NINJAS_API_KEY |
| Random Quote | A random quote | API_NINJAS_API_KEY |
| Crypto Prices | Current prices for chosen pairs | API_NINJAS_API_KEY |
A new install seeds a clock screen alongside the quotes. Weather is opt-in because it needs a location: screens render on the server, so there is no per-viewer geolocation or time zone — both are explicit settings.
Writing your own plugin is one file plus one line in the registry — see PLUGINS.md.
| Key | Action |
|---|---|
Enter / Space |
Next message |
Arrow Left |
Previous message |
Arrow Right |
Next message |
F |
Toggle fullscreen |
M |
Cycle sound mode (Authentic / Soft / Joke / Mute) |
R |
Toggle random message order |
C |
Cycle display mode (Color / Matrix / Grayscale) |
Escape |
Exit fullscreen |
Each tile on the board is an independent split-flap element with top/bottom halves that animate through an ordered character-stepping sequence — just like a real mechanical board. Only tiles whose content changes between messages animate. Emojis are supported: tiles flip through random charset characters before snapping to the final emoji.
Sound is generated per-tile using extracted tick slices from a recorded split-flap audio clip, with stereo panning based on tile position. A master audio chain applies lowpass filtering, EQ, and compression across four sound profiles. Authentic mode is the default.
The browser holds no configuration of its own. constants.js blocks on /api/config at
startup and retries until the server answers, so a display started before the backend
simply waits and then comes up. After that, the server pushes message_state and
config_state frames over /ws; content changes swap the rotation in place, and only a
layout change — grid, charset, palette, animation timing — reloads the page.
A pnpm workspace with three packages: board (the display), admin (the dashboard) and
backend (the server). The two frontends share no code — they are separate Vite builds,
served by the backend at /b/{slug} and / respectively.
flipoff/
pnpm-workspace.yaml — Workspace definition
Dockerfile — Container image (builds both frontends, runs the backend)
docker-compose.yml — Docker Compose with persistent volume
PLUGINS.md — Plugin development guide
board/
index.html — The display page
boards.html — The public board index at /b
vite.config.ts — Build config: base '/b/', dev proxy to the backend
public/
images/ — Screenshot and other static images
src/
main.js — Entry point, audio init, tile fitting, fullscreen, remote sync
Board.js — Tile grid, display modes, transitions
Tile.js — Split-flap flip with character stepping and emoji support
SoundEngine.js — Sound profiles, tick extraction, stereo panning
MessageRotator.js — Shuffle, random mode, remote override
KeyboardController.js — Keyboard shortcuts (F, M, R, C, arrows, etc.)
RemoteMessageSync.js — WebSocket sync and reconnection
boardsIndex.js — The /b index: fetches /api/boards and lists them
config.js — Board slug, /api/config fetch with retry, boot presentation
statusScreen.js — Connecting / failure / reconnecting screens
flapAudio.js — Embedded base64 audio clip
audio/ — Joke mode: rubber duck squeak, fart finisher
css/
reset.css — CSS reset
layout.css — Page layout, nav icon buttons, tooltips, loading bar, fullscreen
board.css — Board container, accent bars, shortcuts overlay
boardsIndex.css — The /b index page
tile.css — Split-flap tile halves and flip animations
responsive.css — Media queries for the chrome (tile sizing is main.js's job)
admin/ — React + TypeScript dashboard
index.html — App shell
vite.config.ts — Build config: base '/', dev proxy to the backend and board
src/
main.tsx — Entry point and routes (/, /boards, /send, /settings)
App.tsx — Session gate and the board list
api/ — Typed client and the admin API response shapes
state/ — Board list, per-board workspace, status banner, router context
components/ — Layout, login, screen modal, plugin schema fields
pages/ — Home, Boards, a board's workspace (config + screens), Send, Settings
lib/ — Formatting and screen helpers
css/
reset.css — CSS reset (its own copy; the packages share nothing)
admin.css — Admin dashboard styles
backend/
src/
main.ts — Entry point, http server, WebSocket upgrade
app.ts — Express app: middleware, routes, static serving
types.ts — Shared state and screen types
config/ — Data paths, app constants, and seed.ts (a new board's values)
board/ — Serialization, screens, registry, validation
db/ — SQLite: boards, screens, migrations, first-run seed
auth/ — bcrypt password handling and admin sessions
ws/ — WebSocket hub and per-board broadcast
routes/ — pages, /api, /api/admin, /api/admin/boards[/screens]
middleware/ — Cache headers
plugins/
base.ts — Plugin types, manifests, schema validation
index.ts — Plugin registry (add new plugins here)
runtime.ts — Refresh loops and error handling
datetime/ — Clock
weather/ — Open-Meteo current conditions and 3-day forecast
github/ — Repo stats, open issues/PRs
api-ninjas/ — Quote of the Day, random quotes, crypto prices
Everything that describes a board — its size, timings, charset, accent colours, corner
markers, loading-line mode and transition order — is edited per board in the admin
dashboard, under Boards. Changes persist to ~/.flipoff/flipoff.db and reach open
displays immediately over the WebSocket, without a reload. A grid resize is the exception:
it rebuilds the board, so those displays do reload.
Its screens — the rotation itself — are edited alongside its configuration, at Boards → (a board) → Screens.
backend/src/config/seed.ts holds what a board is born with: grid, timings, charset,
accent colours, corners, loading mode, transition mode and the messages its manual screens
start from.
It is read in one place, when a board row is inserted, and nowhere else. Editing it therefore has no effect on an install that already has boards — it changes what the next board you create gets, and what a fresh install's first board gets. There is nothing to bind-mount and no restart-time override: change a running board in the admin instead.
Note that charset drives the flip animation rather than filtering text. Characters
outside it, emoji included, still render.
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
Server listen port |
ADMIN_PASSWORD |
Auto-generated | Admin dashboard password |
API_NINJAS_API_KEY |
— | API key for quote and crypto price plugins |
FLIPOFF_DATA_DIR |
~/.flipoff |
Where flipoff.db (boards and screens) persists |
MIT — do whatever you want with it.
