AppCrumble mascotAppCrumble
← 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>&uarr;&darr; NAVIGATE</span>
            <span>&crarr; 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.