← Back to Crumbs
InterfacesReactTypeScriptTailwind
Departure Board Palette
A command palette drawn as an airport split-flap departures board - every command is a flight, with a destination, a gate, and a status column that reads SELECTED once you've keyed to it. Rows flip into place letter by letter, the way a physical board resolves.
Live preview
Code
departure-board-palette.tsx
"use client";
import * as React from "react";
export interface DepartureCommand {
id: string;
label: string;
group?: string;
shortcut?: string[];
}
export interface DepartureBoardPaletteProps {
commands: DepartureCommand[];
open?: boolean;
onOpenChange?: (open: boolean) => void;
onSelect?: (command: DepartureCommand) => void;
placeholder?: string;
hotkey?: boolean;
closeOnSelect?: boolean;
className?: string;
}
const FLAP_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 &-.'";
function SplitFlap({
text,
className = "",
flipMs = 480,
}: {
text: string;
className?: string;
flipMs?: number;
}) {
const target = text.toUpperCase();
const [display, setDisplay] = React.useState(target);
React.useEffect(() => {
const chars = target.split("");
const start = performance.now();
const totalMs = flipMs + flipMs * 0.3;
let raf = 0;
function tick(now: number) {
const elapsed = now - start;
const next = chars.map((ch, i) => {
const settleAt = Math.min(flipMs, ((i + 1) / chars.length) * flipMs) + flipMs * 0.3;
if (ch === " " || elapsed >= settleAt) return ch;
return FLAP_CHARS[Math.floor(elapsed / 40 + i * 3) % FLAP_CHARS.length];
});
setDisplay(next.join(""));
if (elapsed < totalMs) raf = requestAnimationFrame(tick);
else setDisplay(target);
}
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [target, flipMs]);
return <span className={className}>{display}</span>;
}
export function DepartureBoardPalette({
commands,
open,
onOpenChange,
onSelect,
placeholder = "Search commands...",
hotkey = true,
closeOnSelect = true,
className = "",
}: DepartureBoardPaletteProps) {
const isControlled = open !== undefined;
const [internalOpen, setInternalOpen] = React.useState(false);
const isOpen = isControlled ? (open as boolean) : internalOpen;
const [query, setQuery] = React.useState("");
const [activeIndex, setActiveIndex] = React.useState(0);
const [now, setNow] = React.useState<Date | null>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const rowRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const listboxId = React.useId();
function setOpen(next: boolean) {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
}
React.useEffect(() => {
if (!hotkey) return;
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen(true);
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hotkey]);
React.useEffect(() => {
if (!isOpen) return;
setQuery("");
setActiveIndex(0);
setNow(new Date());
const clock = setInterval(() => setNow(new Date()), 1000);
const raf = requestAnimationFrame(() => inputRef.current?.focus());
return () => {
clearInterval(clock);
cancelAnimationFrame(raf);
};
}, [isOpen]);
const q = query.trim().toLowerCase();
const filtered = q
? commands.filter(
(c) => c.label.toLowerCase().includes(q) || c.group?.toLowerCase().includes(q),
)
: commands;
React.useEffect(() => {
setActiveIndex((i) => Math.min(i, Math.max(filtered.length - 1, 0)));
}, [filtered.length]);
React.useEffect(() => {
rowRefs.current[activeIndex]?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
function selectCommand(command: DepartureCommand) {
onSelect?.(command);
if (closeOnSelect) setOpen(false);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
const count = filtered.length;
if (e.key === "ArrowDown") {
e.preventDefault();
if (count > 0) setActiveIndex((i) => (i + 1) % count);
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (count > 0) setActiveIndex((i) => (i - 1 + count) % count);
} else if (e.key === "Enter") {
e.preventDefault();
const cmd = filtered[activeIndex];
if (cmd) selectCommand(cmd);
} else if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
}
}
if (!isOpen) return null;
return (
<>
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/70 px-4 pb-10 pt-[12vh]"
onClick={() => setOpen(false)}
>
<div
role="dialog"
aria-modal="true"
aria-label="Command palette"
onClick={(e) => e.stopPropagation()}
className={[
"relative w-full max-w-[640px] overflow-hidden rounded-xl border border-[#ddd3bd] bg-[#f7f3ea] shadow-[0_20px_60px_rgba(0,0,0,0.35)] dark:border-[#2a2e35] dark:bg-[#111318] dark:shadow-[0_20px_60px_rgba(0,0,0,0.55)]",
className,
]
.filter(Boolean)
.join(" ")}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-[0.05]"
style={{
backgroundImage:
"repeating-linear-gradient(0deg, #000 0px, #000 1px, transparent 1px, transparent 3px)",
}}
/>
<div className="relative flex items-center justify-between border-b border-[#ddd3bd] bg-[#eee7d8] px-4 py-2.5 dark:border-[#2a2e35] dark:bg-[#0b0d10]">
<SplitFlap
text="Departures"
className="font-mono text-[12px] font-bold tracking-[0.25em] text-[#a5540a] dark:text-[#ffb703]"
/>
<span className="font-mono text-[12px] tabular-nums tracking-widest text-[#6b6046] dark:text-[#7d838c]">
{now ? now.toLocaleTimeString("en-GB", { hour12: false }) : ""}
</span>
</div>
<div className="relative border-b border-[#ddd3bd] px-4 py-3 dark:border-[#2a2e35]">
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded="true"
aria-controls={listboxId}
aria-activedescendant={
filtered[activeIndex] ? `${listboxId}-option-${filtered[activeIndex].id}` : undefined
}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
autoComplete="off"
spellCheck={false}
className="w-full bg-transparent font-mono text-[15px] text-[#2e2a22] placeholder:text-[#a89b7e] focus:outline-none dark:text-[#f4f1ea] dark:placeholder:text-[#5a5f68]"
/>
</div>
<div className="relative grid grid-cols-[1fr_88px_108px] gap-2 px-4 pb-1.5 pt-3 font-mono text-[10px] tracking-[0.15em] text-[#6b6046] dark:text-[#5a5f68]">
<span>DESTINATION</span>
<span>GATE</span>
<span className="text-right">STATUS</span>
</div>
<div
id={listboxId}
role="listbox"
className="relative max-h-[320px] overflow-y-auto px-2 pb-2"
>
{filtered.length === 0 ? (
<div className="px-2 py-10 text-center">
<div className="mb-1 font-mono text-[13px] tracking-[0.15em] text-[#a5540a] dark:text-[#ffb703]">
NO FLIGHTS FOUND
</div>
<div className="font-mono text-[11px] text-[#6b6046] dark:text-[#7d838c]">
Try another destination.
</div>
</div>
) : (
filtered.map((command, i) => {
const isActive = i === activeIndex;
return (
<button
key={command.id}
ref={(el) => {
rowRefs.current[i] = el;
}}
id={`${listboxId}-option-${command.id}`}
role="option"
aria-selected={isActive}
type="button"
onMouseEnter={() => setActiveIndex(i)}
onClick={() => selectCommand(command)}
className={[
"grid w-full grid-cols-[1fr_88px_108px] items-center gap-2 rounded-md border-l-2 px-2 py-2 text-left transition-colors duration-100",
isActive
? "border-[#a5540a] bg-[#a5540a]/10 dark:border-[#ffb703] dark:bg-[#ffb703]/[0.08]"
: "border-transparent hover:bg-black/[0.03] dark:hover:bg-white/[0.03]",
].join(" ")}
>
<SplitFlap
text={command.label}
className="truncate font-mono text-[14px] font-semibold tracking-[0.04em] text-[#a5540a] dark:text-[#ffb703]"
/>
<SplitFlap
text={command.group ?? "—"}
className="truncate font-mono text-[11px] text-[#6b6046] dark:text-[#9aa0a8]"
/>
<span className="flex justify-end">
{isActive ? (
<span className="font-mono text-[10px] font-bold tracking-[0.15em] text-[#a5540a] dark:text-[#ffb703]">
SELECTED
</span>
) : command.shortcut && command.shortcut.length > 0 ? (
<span className="flex gap-1">
{command.shortcut.map((key, keyIndex) => (
<kbd
key={keyIndex}
className="rounded border border-[#ddd0b3] bg-[#efe7d5] px-1.5 py-0.5 font-mono text-[10px] text-[#6b604a] dark:border-[#34383f] dark:bg-[#1c1f24] dark:text-[#9aa0a8]"
>
{key}
</kbd>
))}
</span>
) : (
<span className="font-mono text-[10px] tracking-[0.1em] text-[#6b6046] dark:text-[#5a5f68]">
ON TIME
</span>
)}
</span>
</button>
);
})
)}
</div>
<div className="relative flex justify-center gap-4 border-t border-[#ddd3bd] bg-[#eee7d8] px-4 py-2 font-mono text-[10px] tracking-[0.1em] text-[#6b6046] dark:border-[#2a2e35] dark:bg-[#0b0d10] dark:text-[#5a5f68]">
<span>↑↓ NAVIGATE</span>
<span>↵ SELECT</span>
<span>ESC CLOSE</span>
</div>
</div>
</div>
</>
);
}
Usage
usage.tsx
import { useState } from "react";
import { DepartureBoardPalette } from "@/components/departure-board-palette";
const COMMANDS = [
{ id: "new-project", label: "New Project", group: "Actions", shortcut: ["⌘", "N"] },
{ id: "search-docs", label: "Search Docs", group: "Navigate" },
];
export function AppShell() {
const [open, setOpen] = useState(false);
return (
<DepartureBoardPalette
commands={COMMANDS}
open={open}
onOpenChange={setOpen}
onSelect={(command) => console.log(command.id)}
/>
);
}Dependencies
None - just React and Tailwind.
Responsive
The board is capped at 640px wide and scrolls internally past 320px of rows, so it works as a centered overlay from a small laptop up to a wide desktop. Cmd/Ctrl+K opens it from anywhere on the page by default (set hotkey={false} to wire your own trigger only), works as a controlled component (open + onOpenChange) or uncontrolled, and is fully keyboard-driven - arrow keys to move, Enter to select, Escape to close - with the matching combobox/listbox ARIA roles.
Dark mode
Switches between a cream daytime board with warm amber-brown text and a dark charcoal board with bright amber text via Tailwind's dark: variant - the dimmed backdrop behind the board stays the same in both.