Split-flap board
A dark airport departure board whose six cream split-flap tiles clatter through the alphabet to spell ARRIVE, then ON TIME, flipping between the two every few seconds while a blinking amber status light tracks the update.
Code: MIT license.
<!doctype html>
<html lang="en">
<head
><script>
/* 6to9 pattern harness */ (function () {
var parentPaused = false,
hidden = document.hidden,
p = (window.__pattern = { paused: false, reducedMotion: false, maxDpr: 2 });
function sync() {
p.paused = parentPaused || hidden;
}
sync();
try {
p.reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
} catch (e) {}
document.addEventListener("visibilitychange", function () {
hidden = document.hidden;
sync();
});
addEventListener("message", function (e) {
var d = e.data || {};
if (d.pattern === "pause") {
parentPaused = true;
sync();
}
if (d.pattern === "play") {
parentPaused = false;
sync();
}
});
})();
</script>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Halcyon Air - Live Board</title>
<style>
:root {
--tw: min(calc((100vw - 90px) / 6.55), 168px);
--th: calc(var(--tw) * 1.36);
--tile-top: linear-gradient(#f5eedf, #e8dfca);
--tile-bottom: linear-gradient(#e2d8c2, #eee6d3);
--ink: #1c1a17;
--amber: #ffb020;
}
@media (max-width: 600px) {
:root {
--th: calc(var(--tw) * 1.62);
}
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
background: #0e0d0c;
color: #efe7d6;
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif;
}
.hero {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: clamp(16px, 3vh, 32px);
padding: 20px 16px;
background: radial-gradient(ellipse at 50% 35%, #2a2620 0%, #151311 55%, #0b0a09 100%);
}
.status {
display: flex;
align-items: center;
gap: 12px;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 12px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #b7ab95;
}
.light {
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--amber);
box-shadow:
0 0 10px 2px rgba(255, 176, 32, 0.75),
0 0 28px 6px rgba(255, 176, 32, 0.35);
}
.light.busy {
animation: blink 0.5s steps(2, jump-none) infinite;
}
@keyframes blink {
0% {
opacity: 1;
}
100% {
opacity: 0.25;
}
}
.board {
display: flex;
padding: clamp(12px, 2.2vw, 28px);
gap: clamp(4px, 0.6vw, 9px);
background: #0a0a0a;
border-radius: 18px;
box-shadow:
0 50px 90px rgba(0, 0, 0, 0.65),
0 8px 0 #050505,
inset 0 0 0 1px #28251f,
inset 0 2px 0 rgba(255, 255, 255, 0.05);
}
.tile {
position: relative;
width: var(--tw);
height: var(--th);
margin-right: 0;
border-radius: 8px;
transition: margin-right 0.7s cubic-bezier(0.6, 0, 0.2, 1);
font-family: "Helvetica Neue", Helvetica, Arial, system-ui, sans-serif;
font-weight: 800;
font-size: min(calc(var(--th) * 0.74), var(--tw));
color: var(--ink);
user-select: none;
}
.board.spaced .tile:nth-child(2) {
margin-right: calc(var(--tw) * 0.55);
}
.half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
}
.half.top {
top: 0;
background: var(--tile-top);
border-radius: 8px 8px 0 0;
}
.half.bottom {
bottom: 0;
background: var(--tile-bottom);
border-radius: 0 0 8px 8px;
}
.half span {
position: absolute;
left: 0;
right: 0;
height: 200%;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.half.top span {
top: 0;
}
.half.bottom span {
bottom: 0;
}
.flap {
visibility: hidden;
backface-visibility: hidden;
will-change: transform;
z-index: 2;
}
.flap.top {
transform-origin: 50% 100%;
}
.flap.bottom {
transform-origin: 50% 0;
transform: perspective(600px) rotateX(90deg);
}
.hinge {
position: absolute;
left: 0;
right: 0;
top: calc(50% - 1px);
height: 2px;
background: #0a0908;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.18);
z-index: 5;
}
.hinge::before,
.hinge::after {
content: "";
position: absolute;
top: -4px;
width: 4px;
height: 10px;
background: #0a0908;
}
.hinge::before {
left: -1px;
border-radius: 0 3px 3px 0;
}
.hinge::after {
right: -1px;
border-radius: 3px 0 0 3px;
}
.copy {
display: flex;
flex-direction: column;
align-items: center;
gap: clamp(14px, 2.4vh, 24px);
text-align: center;
max-width: 520px;
}
.subline {
margin: 0;
font-size: clamp(15px, 1.5vw, 19px);
line-height: 1.5;
color: #cfc5b1;
}
.cta {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 15px 26px;
border: 0;
border-radius: 999px;
background: #efe7d6;
color: #1c1a17;
font: inherit;
font-weight: 700;
font-size: 16px;
cursor: pointer;
box-shadow:
0 4px 0 #a99f8b,
0 14px 30px rgba(0, 0, 0, 0.45);
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.cta:hover {
transform: translateY(-2px);
box-shadow:
0 6px 0 #a99f8b,
0 18px 34px rgba(0, 0, 0, 0.5);
}
.cta:active {
transform: translateY(3px);
box-shadow:
0 1px 0 #a99f8b,
0 6px 12px rgba(0, 0, 0, 0.4);
}
.cta svg {
width: 18px;
height: 18px;
}
@media (max-width: 600px) {
.status {
font-size: 11px;
letter-spacing: 0.16em;
}
}
</style>
</head>
<body>
<main class="hero">
<div class="status">
<span class="light busy" id="light"></span>
<span id="statusText">Updating board</span>
</div>
<h1 class="board" id="board" role="img" aria-label="ARRIVE" style="margin: 0"></h1>
<div class="copy">
<p class="subline">Live gate times for Halcyon Air. Calm, clear and always current.</p>
<button class="cta" type="button">
Track a flight
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M5 12h14" />
<path d="M13 6l6 6-6 6" />
</svg>
</button>
</div>
</main>
<script>
const P = window.__pattern || { paused: false, reducedMotion: false, maxDpr: 2 };
const state = function () {
return window.__pattern || P;
};
const WORDS = [
{ text: "ARRIVE", label: "ARRIVE", status: "Gate 07 / Landed" },
{ text: "ONTIME", label: "ON TIME", status: "Gate 07 / On time" },
];
const ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const board = document.getElementById("board");
const light = document.getElementById("light");
const statusText = document.getElementById("statusText");
const tiles = [];
function makeHalf(cls) {
const half = document.createElement("div");
half.className = "half " + cls;
const span = document.createElement("span");
half.appendChild(span);
return { el: half, span: span };
}
function makeTile() {
const el = document.createElement("div");
el.className = "tile";
el.setAttribute("aria-hidden", "true");
const top = makeHalf("top");
const bottom = makeHalf("bottom");
const flapTop = makeHalf("top flap");
const flapBottom = makeHalf("bottom flap");
const hinge = document.createElement("div");
hinge.className = "hinge";
el.appendChild(top.el);
el.appendChild(bottom.el);
el.appendChild(flapTop.el);
el.appendChild(flapBottom.el);
el.appendChild(hinge);
return {
el: el,
top: top.span,
bottom: bottom.span,
flapTop: flapTop.el,
flapTopSpan: flapTop.span,
flapBottom: flapBottom.el,
flapBottomSpan: flapBottom.span,
char: " ",
};
}
function setChar(tile, ch) {
tile.char = ch;
tile.top.textContent = ch;
tile.bottom.textContent = ch;
}
for (let i = 0; i < 6; i++) {
const tile = makeTile();
tiles.push(tile);
board.appendChild(tile.el);
setChar(tile, " ");
}
function setStatus(text, busy) {
statusText.textContent = text;
light.classList.toggle("busy", busy);
}
// Time that only advances while the page is not paused.
function sleep(ms) {
return new Promise(function (resolve) {
let remaining = ms;
let last = performance.now();
function tick(now) {
if (state().paused) {
last = now;
} else {
remaining -= now - last;
last = now;
}
if (remaining <= 0) {
resolve();
} else {
requestAnimationFrame(tick);
}
}
requestAnimationFrame(tick);
});
}
function gate() {
return new Promise(function (resolve) {
function check() {
if (state().paused) {
requestAnimationFrame(check);
} else {
resolve();
}
}
check();
});
}
// Freeze or resume every running Web Animation when paused flips.
let wasPaused = false;
function watchPause() {
const now = !!state().paused;
if (now !== wasPaused) {
wasPaused = now;
document.getAnimations().forEach(function (a) {
if (now) {
a.pause();
} else {
a.play();
}
});
light.style.animationPlayState = now ? "paused" : "running";
}
requestAnimationFrame(watchPause);
}
requestAnimationFrame(watchPause);
// One flap: the old top half falls, then the new bottom half lands.
async function flip(tile, ch, dur) {
await gate();
tile.flapTopSpan.textContent = tile.char;
tile.bottom.textContent = tile.char;
tile.top.textContent = ch;
tile.flapBottomSpan.textContent = ch;
tile.flapTop.style.visibility = "visible";
const fall = tile.flapTop.animate(
[
{ transform: "perspective(600px) rotateX(0deg)", filter: "brightness(1)" },
{ transform: "perspective(600px) rotateX(-90deg)", filter: "brightness(0.55)" },
],
{ duration: dur * 0.5, easing: "cubic-bezier(0.5, 0, 1, 0.8)", fill: "forwards" },
);
await fall.finished;
tile.flapTop.style.visibility = "hidden";
fall.cancel();
tile.flapBottom.style.visibility = "visible";
const land = tile.flapBottom.animate(
[
{ transform: "perspective(600px) rotateX(90deg)", filter: "brightness(0.7)" },
{ transform: "perspective(600px) rotateX(0deg)", filter: "brightness(1)" },
],
{ duration: dur * 0.5, easing: "cubic-bezier(0.2, 0.6, 0.4, 1)", fill: "forwards" },
);
await land.finished;
tile.bottom.textContent = ch;
tile.flapBottom.style.visibility = "hidden";
land.cancel();
tile.char = ch;
}
// Clatter through the alphabet and land on the target letter.
async function rollTo(tile, index, target) {
const flips = 9 + index * 5 + Math.floor(Math.random() * 3);
const dur = 64 + index * 6;
const targetIndex = ALPHA.indexOf(target);
for (let k = 0; k < flips; k++) {
const ch = ALPHA[(targetIndex - (flips - 1 - k) + 2600) % 26];
await flip(tile, ch, dur);
}
await gate();
tile.el.animate(
[
{ transform: "translateY(0) scaleY(1)" },
{ transform: "translateY(3px) scaleY(0.985)" },
{ transform: "translateY(0) scaleY(1)" },
],
{ duration: 150, easing: "ease-out" },
);
}
function showStatic(word) {
tiles.forEach(function (tile, i) {
setChar(tile, word.text[i]);
});
board.setAttribute("aria-label", word.label);
board.classList.toggle("spaced", word.text === "ONTIME");
setStatus(word.status, false);
}
async function run() {
let w = 0;
while (true) {
const word = WORDS[w];
setStatus("Updating board", true);
board.classList.toggle("spaced", w === 1);
await Promise.all(
tiles.map(function (tile, i) {
return sleep(i * 60).then(function () {
return rollTo(tile, i, word.text[i]);
});
}),
);
board.setAttribute("aria-label", word.label);
setStatus(word.status, false);
await sleep(3000);
w = 1 - w;
}
}
try {
if (state().reducedMotion || typeof Element.prototype.animate !== "function") {
showStatic(WORDS[0]);
} else {
run().catch(function () {
showStatic(WORDS[0]);
});
}
} catch (e) {
showStatic(WORDS[0]);
}
</script>
</body>
</html>
Made by 6to9. Back to all motion patterns.