Flap board
A split-flap board clatters through to the target string.
Customize
import {Scene, Video} from "odori";
import {FlapBoard} from "../components/flap-board/flap-board";
<Video>
<Scene id="flap-board" duration="5s">
<FlapBoard
text="NOW BOARDING"
stagger={4}
flips={5}
tile={true}
/>
</Scene>
</Video>Installation
pnpm odori add component flap-boardnpx odori add component flap-boardbunx odori add component flap-boardEdit the source in videos/components/flap-board/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/flap-board/flap-board.tsx
import {Fill, random, useBrand, useFrame, useDesignScale} from "odori";
export type FlapBoardProps = {
text: string;
/** Frames between one cell starting and the next. */
stagger?: number;
/** Intermediate characters each cell flips through. */
flips?: number;
/** Render the dark tiles, or bare glyphs on the background. */
tile?: boolean;
};
const FLAP_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
/** Frames one flap takes; short enough to clatter, long enough to read. */
const FLIP_FRAMES = 6;
/**
* A split-flap departure board. Each cell is a fixed-width tile that flips
* through seeded intermediates before landing on its glyph, settling left to
* right. A flap is a vertical squash through zero with the character swapped
* at the midpoint — the moment the tile is edge-on and nobody can see the
* switch. The intermediates are seeded by cell and flap index, so every
* render clatters through the same sequence.
*/
export const FlapBoard = ({text, stagger = 4, flips = 5, tile = true}: FlapBoardProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const cells = [...text];
return (
<Fill style={{alignItems: "center", justifyContent: "center", padding: `${120 * scale}px ${140 * scale}px`}}>
<div style={{display: "flex", gap: 8 * scale}}>
{cells.map((target, index) => {
const blank = target === " ";
const t = Math.min(flips, (frame - (4 + index * stagger)) / FLIP_FRAMES);
const settled = blank || t >= flips;
// The shown character advances at the midpoint of each flap.
const step = Math.max(0, Math.min(flips, Math.round(t)));
const intermediate = FLAP_ALPHABET[Math.floor(random(["flap", index, step]) * FLAP_ALPHABET.length)];
const shown = blank ? "" : step >= flips ? target : intermediate;
const squash = settled || t < 0 ? 1 : Math.abs(Math.cos(Math.PI * t));
return (
<div
key={`${target}-${index}`}
style={{
alignItems: "center",
background: tile ? brand.colors.surface : "transparent",
border: tile ? `1px solid ${brand.colors.border}` : "none",
borderRadius: 8 * scale,
display: "flex",
fontFamily: brand.typography.mono,
fontSize: 88 * scale,
fontWeight: 600,
height: 128 * scale,
justifyContent: "center",
position: "relative",
transform: `scaleY(${squash})`,
width: 76 * scale,
}}
>
<span style={{whiteSpace: "pre"}}>{shown}</span>
{tile ? (
// The hairline seam where a real board's two flaps meet.
<div
style={{
background: brand.colors.background,
height: Math.max(1, 2 * scale),
left: 0,
position: "absolute",
right: 0,
top: "50%",
}}
/>
) : null}
</div>
);
})}
</div>
</Fill>
);
};
videos/components/flap-board/flap-board.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {FlapBoard} from "./flap-board";
export default defineComponentPreview({
title: "Flap board",
category: "Typography/Titles",
description: "A split-flap board clatters through to the target string.",
component: FlapBoard,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
text: {type: "text", defaultValue: "NOW BOARDING", maxLength: 16},
stagger: {type: "number", defaultValue: 4, min: 0, max: 12},
flips: {type: "number", defaultValue: 5, min: 1, max: 12},
tile: {type: "boolean", defaultValue: true},
},
examples: [
{name: "Default", props: {text: "NOW BOARDING"}},
{name: "Long clatter", props: {text: "DELAYED", flips: 10, stagger: 6}},
{name: "Bare glyphs", props: {text: "GATE 42", tile: false}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {FlapBoard} from "../components/flap-board/flap-board";
<Video>
<Scene id="flap-board" duration="5s">
<FlapBoard
text="NOW BOARDING"
stagger={4}
flips={5}
tile={true}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Typography
- Recommended duration
- 150 frames · 5s
- Minimum duration
- 100 frames · 3.33s
- Entrance
- 94 frames
- Exit
- 0 frames
- Reduced motion
- cells show their target characters without flipping
- Requires
- mono font
- Content limits
- text ≤ 16
Props
| Prop | Type | Default | Required |
|---|---|---|---|
text | string | — | Yes |
staggerFrames between one cell starting and the next. | number | 4 | — |
flipsIntermediate characters each cell flips through. | number | 5 | — |
tileRender the dark tiles, or bare glyphs on the background. | boolean | true | — |
Related components
All componentsCaptionsFrame-accurate caption cues for sound-off playback.Character riseCharacters rise with a crisp per-letter stagger.Draw titleAn outline draws across the line, then fills solid.Focus inWords settle from a soft blur into a crisp line.Glitch textA title that arrives through brief seeded interference bursts.Kinetic centerA centered statement assembled from timed word groups.