A ferry pier departures board: every character is a CSS 3D split flap that rattles through the drum to its letter, rows staggered, the last flip bouncing. A flap clock ticks; Pause stops it.
The departures board on a ferry pier. Every character is a split flap built in CSS 3D: the top half falls on a hinge at the split, darkening as it turns away from the light, and the bottom half follows it down, lands, bounces a little and settles. Cells rattle through the drum to their letters, row after row; the flap clock ticks, and every few seconds a remark changes (boarding, gate closed, delayed). A Pause toggle stops all of it.
Built from pulse (the boarding lamp) and fade (the column labels). Copy CSS in the gallery gives one file with tokens, both patterns and this template.
The board is a real <table>. In each cell, .flaps holds one span.c per character (hidden from screen readers) and a .sr-only span says the value in words ("Delayed 15 minutes"). Field widths are fixed: time 5, destination 12, vessel 8, pier 2, remarks 10. Pad shorter values with spaces.
What moves and when
Time
What moves
0 to 0.7s
The ferry pictogram sails in on the sign; the column labels fade up
0.1 to 2.6s
Every cell starts 4 to 14 characters behind its letter and flips through the drum, 72ms a flip. Rows start 170ms apart, cells 26ms apart within a row. The last flip of each run is slower (70ms down, 190ms up) and bounces 16 degrees
2.8 to 4.4s
A band of light runs once across the board's glass
From 3.2s
The clock ticks every second; every 7 seconds a remark changes
Always
The clock colon blinks and the boarding lamp beats; Pause stops both, and the ticking
The flap
Each .c gets four halves from board.js:
Half
Shows
Does
fixed top .u
the next character
stays
fixed bottom .d
the current character
stays
top flap .fu
the current character
falls from 0 to -90 degrees on a hinge at its bottom edge (transform-origin: 50% 100%), brightness 1 to .42
bottom flap .fd
the next character
comes down from 90 to 0 degrees on a hinge at its top edge, brightness .5 to 1
The cell has its own perspective, so each flap foreshortens around its own hinge. Halves use backface-visibility: hidden and a slightly lighter top colour than bottom, like a lit board.
JS notes
board.js (about 3 KB gzipped) runs one requestAnimationFrame scheduler that starts each flip when it is due and stops when nothing is left to turn. Flips are Web Animations on transform and filter, with their curves read from tokens.css (--ease-in for the fall, --ease-out for the landing). Characters come from one drum (" ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:.-"), so a cell can only reach N by passing M, like the real thing; the clock cells use a digits-only drum so 9 turns straight to 0. The clock and the remarks run on a one-second interval that does nothing while Pause is checked.
Make it yours
Rows: add a <tr> with the same five cells. On short frames (under 560px tall) only the first five rows show.
Colours:--flap-top, --flap-bot and --flap-ink on .c; .amber, .red and .grey on a cell recolour its flaps.
Remarks: the updates list in board.js is row number, new text, tone.
Phones: under 640px each departure is two lines: the destination large, then time, remarks and pier. The vessel stays in the table for screen readers.
Reduced motion
board.js does nothing: the board shows as written, the clock does not tick, nothing flips, the colon and the lamp are still. The Pause toggle is hidden because nothing moves.
Without JavaScript
The same board as written: every character on its own two-tone tile with the split line across it.
Template CSS
This is the template's own layer. Copy the full CSS above for one file with the tokens and every pattern it uses. It also ships board.js.
/*
* Split-flap board: the departures board on a ferry pier. Every character is a .c cell. Without
* JavaScript (and under reduced motion) a cell is a two-tone tile with the character on it and
* a split line across the middle, so the board reads as finished. board.js turns each cell into
* four halves: two fixed (the next character on top, the current one below) and two flaps. The
* top flap falls on a hinge at the split, darkening as it turns away from the light; the bottom
* flap carries on down, lands, bounces a little and settles.
*
* CSS does the scene around the flaps (one --stagger step is 80ms):
* 0 to 0.7s the ferry pictogram sails in on the sign
* 0 to 0.6s the column labels fade up
* 2.8 to 4.4s once the flaps have settled, a band of light runs across the board's glass
* always the colon in the clock blinks and the boarding lamp beats; Pause stops both
*/@font-face { font-family: "Anybody"; src: url("../../fonts/anybody-vf.woff2") format("woff2"); font-weight: 100900; font-stretch: 50% 150%; font-display: swap; }
@font-face { font-family: "Host Grotesk"; src: url("../../fonts/host-grotesk-vf.woff2") format("woff2"); font-weight: 300800; font-display: swap; }
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; }
body {
min-height: 100vh;
/* the pier wall: mint paint over block courses */background:
repeating-linear-gradient(0deg, rgb(207060 / .07) 01px, transparent 1px44px),
linear-gradient(180deg, #b4e0d3, #9ccfc0);
color: #0e1a17;
font: 50015px/1.45 "Host Grotesk", system-ui, sans-serif;
}
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0000); white-space: nowrap; }
.pier {
--pad: clamp(16px, 3vw, 44px);
/* one cell: 41 cells wide fit a row with its column gaps */--cw: clamp(11px, min(calc((100vw - var(--pad) * 2 - 40px) / 41 - 3px), calc((100vh - 250px) / 10.4)), 30px);
--chh: calc(var(--cw) * 1.42);
min-height: 100vh;
display: grid;
grid-template-rows: auto 1fr auto;
gap: clamp(12px, 2.4vh, 24px);
padding: 0 var(--pad) clamp(12px, 2.4vh, 22px);
}
/* ---------- the sign ---------- */.sign {
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: center;
column-gap: clamp(12px, 2vw, 22px);
margin: 0 calc(var(--pad) * -1);
padding: clamp(12px, 2.2vh, 20px) var(--pad);
background: #ffd21f;
border-bottom: 4px solid #0e1a17;
}
.ferry { width: clamp(38px, 4.4vw, 56px); height: auto; color: #0e1a17; }
.sign h1 { margin: 0; font-size: clamp(26px, 3.8vw, 50px); line-height: 1; letter-spacing: -.02em; white-space: nowrap; }
.sign .ms { font-weight: 800; }
.sign .en { font-weight: 350; }
.where { margin: 0; align-self: end; padding-bottom: .3em; font-weight: 600; }
.clock { display: flex; align-items: center; justify-self: end; margin: 0; --cw-clock: calc(var(--cw) * 1.15); }
.clock .c { --cw: var(--cw-clock); --chh: calc(var(--cw-clock) * 1.42); }
.colon { display: inline-block; width: calc(var(--cw) * .55); text-align: center; font: 800 calc(var(--cw) * 1.2) / 1 "Anybody", sans-serif; }
/* ---------- the board ---------- */.board {
position: relative;
align-self: center;
justify-self: center;
max-width: 100%;
padding: clamp(14px, 2.4vh, 26px) clamp(14px, 2vw, 26px) clamp(10px, 1.8vh, 20px);
background: linear-gradient(180deg, #19191c, #0f0f11);
border-radius: 10px;
box-shadow: inset 0002px #2c2c31, inset 02px03px #0a0a0b, 02px0 #0e1a17, 034px50px -24px rgb(146050 / .65);
overflow: hidden;
}
table { border-collapse: collapse; }
th {
padding: 00 clamp(6px, 1.2vh, 12px); text-align: left; color: #ffd21f;
font-size: clamp(11px, 1.05vw, 14px); font-weight: 600;
}
td { padding: 00 calc(var(--cw) * .28); }
th:not(:last-child), td:not(:last-child) { padding-right: calc(var(--cw) * .85); }
.flaps { display: inline-flex; gap: 2px; vertical-align: top; }
.c {
--flap-top: #2a2a2e;
--flap-bot: #222226;
--flap-ink: #f4efe2;
position: relative;
display: inline-block;
flex: none;
width: var(--cw);
height: var(--chh);
border-radius: calc(var(--cw) * .12);
background: linear-gradient(var(--flap-top) 050%, var(--flap-bot) 50% 100%);
color: var(--flap-ink);
font: 700 calc(var(--cw) * 1.12) / var(--chh) "Anybody", sans-serif;
font-stretch: 68%;
text-align: center;
box-shadow: 01px0 rgb(000 / .6);
}
/* the split: a dark gap with a faint lit lip under it */.c::after { content: ""; position: absolute; z-index: 3; left: 0; right: 0; top: calc(50% - .5px); height: 1px; background: #050506; box-shadow: 01px0 rgb(255255255 / .07); }
.amber .c { --flap-top: #ffb41f; --flap-bot: #f2a30c; --flap-ink: #1a1305; }
.red .c { --flap-ink: #ff5b47; }
.grey .c { --flap-ink: #8d8b86; }
.clock .c { --flap-top: #1c1c20; --flap-bot: #141417; }
/* halves, built by board.js */.h { position: absolute; left: 0; right: 0; height: 50%; overflow: hidden; }
.h > i { position: absolute; left: 0; right: 0; height: 200%; font-style: normal; }
.h.u { top: 0; border-radius: calc(var(--cw) * .12) calc(var(--cw) * .12) 00; background: linear-gradient(var(--flap-top), color-mix(in srgb, var(--flap-top), #0008%)); }
.h.d { bottom: 0; border-radius: 00 calc(var(--cw) * .12) calc(var(--cw) * .12); background: linear-gradient(color-mix(in srgb, var(--flap-bot), #fff 3%), var(--flap-bot)); }
.h.u > i { top: 0; }
.h.d > i { top: -100%; }
/* The two moving flaps exist only while a cell turns (.is-flip, set by board.js). A resting cell is flat,
so a board of a few hundred characters does not hand the compositor a few hundred 3D layers. */.h.fu, .h.fd { display: none; z-index: 2; backface-visibility: hidden; }
.is-flip { perspective: calc(var(--cw) * 9); }
.is-flip > .h.fu, .is-flip > .h.fd { display: block; }
.h.fu { transform-origin: 50% 100%; }
.h.fd { transform-origin: 50% 0; transform: rotateX(90deg); }
/* a band of light across the glass, once */.board::after {
content: ""; position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(100deg, rgb(255255255 / 0) 35%, rgb(255255255 / .07) 47%, rgb(255255255 / .02) 53%, rgb(255255255 / 0) 60%);
translate: -110% 0;
}
/* ---------- foot ---------- */.foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px20px; }
.note { display: flex; align-items: center; gap: 10px; margin: 0; font-weight: 600; }
.note .pulse { color: #e08a00; }
.pause { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; cursor: pointer; }
.pause input { accent-color: #0e1a17; }
/* ---------- phones: each departure on two lines, the vessel kept for screen readers ---------- */@media (max-width: 640px) {
.pier { --cw: clamp(11px, calc((100vw - var(--pad) * 2 - 28px) / 18.6 - 3px), 22px); }
.sign { grid-template-columns: auto 1fr; row-gap: 10px; }
.where { grid-column: 1 / -1; grid-row: 3; padding: 0; }
.clock { grid-column: 1 / -1; }
.board { justify-self: stretch; padding-inline: 12px; }
thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0000); }
table, tbody { display: block; }
/* destination large on its own line; time, remarks and pier under it */tr { display: grid; grid-template-columns: auto 1fr auto; grid-template-areas: "dest dest dest" "time status pier";
column-gap: calc(var(--cw) * .6); row-gap: 5px; padding: 10px0; border-bottom: 1px solid #2c2c31; }
tr:last-child { border-bottom: 0; }
.board td { padding: 0; }
.f-dest { grid-area: dest; }
.f-dest .c { --cw: clamp(14px, calc((100vw - var(--pad) * 2 - 28px) / 12.2 - 2px), 34px); --chh: calc(var(--cw) * 1.42); }
.f-time { grid-area: time; }
.f-status { grid-area: status; }
.f-pier { grid-area: pier; }
td.f-vessel { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0000); }
}
@media (max-height: 560px) and (min-width: 641px) {
/* a short frame shows the next five departures */tbody tr:nth-child(n + 6) { display: none; }
.foot { font-size: 13px; }
.where { display: none; }
.sign { grid-template-columns: auto auto 1fr; }
}
/* ---------- choreography ---------- */@keyframes sf-sail { from { opacity: 0; translate: -36px0; } 40% { opacity: 1; } to { opacity: 1; translate: 00; } }
@keyframes sf-glare { from { translate: -110% 0; } to { translate: 110% 0; } }
@keyframes sf-blink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: .15; } }
.js .ferry { animation: sf-sail calc(var(--dur-slow) * .8) var(--ease-out) both; }
.js thead th { animation: fade var(--dur-base) var(--ease-out) both; }
.js .board::after { animation: sf-glare var(--dur-draw) var(--ease-inout) both; animation-delay: calc(var(--stagger) * 35); }
.js .colon { animation: sf-blink 1s steps(1) infinite; }
.pier:has(.pause :checked) .colon,
.pier:has(.pause :checked) .pulse::before,
.pier:has(.pause :checked) .pulse::after { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
.js .ferry, .js thead th, .js .board::after, .js .colon { animation: none; }
.pause { display: none; }
}