Componentticket-stub5.29 KB gzipZero dependencies

Split Flap

Mechanical departure board with flipping characters and retro terminal styling.

Preview

Live Preview
MECHANICAL DEPARTURE TERMINAL[GCT-M4]

GRAND CENTRAL CONCOURSE

MECHANICAL DEPARTURES · SOLARI V4

LOCAL CLOCK08:42:15
FILTER:
TIME
TRAIN/FLT
DESTINATION
STATUS
1
1
4
4
:
:
1
1
0
0
B
B
B
B
-
-
4
4
0
0
2
2
N
N
E
E
W
W
Y
Y
O
O
R
R
K
K
P
P
E
E
N
N
N
N
O
O
N
N
T
T
I
I
M
M
E
E
1
1
4
4
:
:
2
2
5
5
E
E
X
X
P
P
-
-
8
8
8
8
C
C
H
H
I
I
C
C
A
A
G
G
O
O
U
U
N
N
I
I
O
O
N
N
B
B
O
O
A
A
R
R
D
D
I
I
N
N
G
G
1
1
4
4
:
:
4
4
0
0
E
E
X
X
P
P
-
-
1
1
0
0
B
B
O
O
S
S
T
T
O
O
N
N
S
S
O
O
U
U
T
T
H
H
D
D
E
E
L
L
A
A
Y
Y
E
E
D
D
1
1
5
5
:
:
0
0
0
0
B
B
B
B
-
-
5
5
5
5
0
0
W
W
A
A
S
S
H
H
I
I
N
N
G
G
T
T
O
O
N
N
D
D
C
C
O
O
N
N
T
T
I
I
M
M
E
E
★ SOLARI PROTOCOL V4.2 · TICKETS MUST BE VALIDATED AT GATE ENTRANCE ★
PERFORATION CODE #GCT-9910

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add split-flap

Or install the full npm package:

npm install bigbullui
import { SplitFlapBoard } from "@/components/ui/split-flap";

<SplitFlapBoard title="DEPARTURES CONCOURSE" rows={[{ time: "14:10", train: "BB-402", destination: "NEW YORK", track: "04", status: "ON TIME" }]} />

Usage guidance

Use Split Flap when a ticket stub surface needs Mechanical departure board with flipping characters and retro terminal styling. Keep props minimal and prefer composition with sibling stubs.

Avoid Split Flap for long form flows or dense data grids where a dedicated layout block fits better.

Source code

split-flap.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

export interface SplitFlapRowData {
  id?: string;
  train: string;
  time: string;
  destination: string;
  track: string;
  status: "ON TIME" | "BOARDING" | "DELAYED" | "CANCELLED" | "DEPARTED" | "FINAL CALL" | string;
  remarks?: string;
}

export interface SplitFlapCharProps extends React.HTMLAttributes<HTMLDivElement> {
  char?: string;
  size?: "sm" | "md" | "lg";
  color?: "default" | "accent" | "amber" | "green" | "destructive" | "muted";
  sound?: boolean;
  onFlip?: () => void;
  className?: string;
}

export interface SplitFlapTextProps extends React.HTMLAttributes<HTMLDivElement> {
  value: string;
  length?: number;
  size?: "sm" | "md" | "lg";
  color?: "default" | "accent" | "amber" | "green" | "destructive" | "muted";
  stagger?: number;
  sound?: boolean;
  onComplete?: () => void;
  className?: string;
}

export interface SplitFlapRowProps extends React.HTMLAttributes<HTMLDivElement> {
  train: string;
  time: string;
  destination: string;
  track: string;
  status: string;
  remarks?: string;
  sound?: boolean;
  className?: string;
}

export interface SplitFlapBoardProps extends React.HTMLAttributes<HTMLDivElement> {
  title?: string;
  subtitle?: string;
  terminalCode?: string;
  rows?: SplitFlapRowData[];
  sound?: boolean;
  onSoundChange?: (enabled: boolean) => void;
  onFlip?: (char: string, index: number) => void;
  showControls?: boolean;
  className?: string;
}

const FLAP_CHARSET = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-:.#/'*!";

/**
 * Web Audio API synthesizer for mechanical Solari relay click.
 * Zero external audio files, pure browser synthesis.
 */
function playMechanicalClick(audioCtxRef: React.RefObject<AudioContext | null>) {
  if (typeof window === "undefined") return;
  try {
    if (!audioCtxRef.current) {
      const AudioCtx =
        window.AudioContext ||
        (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
      if (AudioCtx) {
        audioCtxRef.current = new AudioCtx();
      }
    }
    const ctx = audioCtxRef.current;
    if (!ctx) return;
    if (ctx.state === "suspended") {
      void ctx.resume();
    }
    const now = ctx.currentTime;

    const osc = ctx.createOscillator();
    const gain = ctx.createGain();
    const filter = ctx.createBiquadFilter();

    filter.type = "bandpass";
    filter.frequency.setValueAtTime(900 + Math.random() * 300, now);
    filter.Q.setValueAtTime(4, now);

    osc.type = "triangle";
    osc.frequency.setValueAtTime(180 + Math.random() * 50, now);
    osc.frequency.exponentialRampToValueAtTime(30, now + 0.02);

    gain.gain.setValueAtTime(0.09, now);
    gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.02);

    osc.connect(filter);
    filter.connect(gain);
    gain.connect(ctx.destination);

    osc.start(now);
    osc.stop(now + 0.02);
  } catch {
    // Autoplay or audio context permission restricted
  }
}

/**
 * SplitFlapChar: Single mechanical flip flap card.
 */
export function SplitFlapChar({
  char = " ",
  size = "md",
  color = "default",
  sound = false,
  onFlip,
  className,
  ...props
}: SplitFlapCharProps) {
  const targetChar = (char[0] || " ").toUpperCase();
  const [displayChar, setDisplayChar] = React.useState(targetChar);
  const [prevChar, setPrevChar] = React.useState(targetChar);
  const [isFlipping, setIsFlipping] = React.useState(false);
  const audioCtxRef = React.useRef<AudioContext | null>(null);

  React.useEffect(() => {
    if (targetChar === displayChar) return;

    let mounted = true;
    let step = 0;
    const maxSteps = 14;

    const startIdx = FLAP_CHARSET.indexOf(displayChar);
    const endIdx = FLAP_CHARSET.indexOf(targetChar);

    let currentIdx = startIdx === -1 ? 0 : startIdx;
    const targetValidIdx = endIdx === -1 ? 0 : endIdx;

    const interval = setInterval(() => {
      if (!mounted) return;
      step += 1;

      if (currentIdx !== targetValidIdx && step < maxSteps) {
        currentIdx = (currentIdx + 1) % FLAP_CHARSET.length;
        const nextChar = FLAP_CHARSET[currentIdx];
        setPrevChar(displayChar);
        setDisplayChar(nextChar);
        setIsFlipping(true);

        if (sound) {
          playMechanicalClick(audioCtxRef);
        }
        if (onFlip) {
          onFlip();
        }

        setTimeout(() => {
          if (mounted) setIsFlipping(false);
        }, 35);
      } else {
        setPrevChar(displayChar);
        setDisplayChar(targetChar);
        setIsFlipping(false);
        clearInterval(interval);
      }
    }, 45);

    return () => {
      mounted = false;
      clearInterval(interval);
    };
  }, [targetChar, sound, onFlip]);

  const sizeClasses = {
    sm: "w-5 h-7 text-xs",
    md: "w-6 h-9 text-sm sm:w-7 sm:h-10 sm:text-base",
    lg: "w-8 h-12 text-lg sm:w-9 sm:h-14 sm:text-xl",
  }[size];

  const colorClasses = {
    default: "text-foreground",
    accent: "text-accent",
    amber: "text-amber-500 dark:text-amber-400",
    green: "text-emerald-700 dark:text-emerald-400",
    destructive: "text-destructive",
    muted: "text-muted-foreground",
  }[color];

  return (
    <div
      className={cn(
        "relative inline-flex flex-col items-center justify-center font-mono font-bold select-none perspective-[300px]",
        sizeClasses,
        className
      )}
      {...props}
    >
      {/* Top Leaf Half */}
      <div className="relative h-1/2 w-full overflow-hidden rounded-t-sm border border-border/80 bg-neutral-900 shadow-inner flex items-end justify-center">
        <span
          className={cn(
            "leading-none translate-y-[50%] block",
            colorClasses
          )}
        >
          {displayChar}
        </span>
        {/* Subtle top glare */}
        <div className="absolute inset-x-0 top-0 h-[1px] bg-white/20" />
      </div>

      {/* Center Hinge Crease & Pins */}
      <div className="relative w-full z-20">
        <div className="h-[1.5px] w-full bg-black/90 shadow-[0_1px_1px_rgba(255,255,255,0.08)]" />
        {/* Left mechanical hinge pin */}
        <div className="absolute -left-[2px] top-1/2 -translate-y-1/2 w-[3px] h-[5px] bg-neutral-500 rounded-r-xs border border-neutral-900" />
        {/* Right mechanical hinge pin */}
        <div className="absolute -right-[2px] top-1/2 -translate-y-1/2 w-[3px] h-[5px] bg-neutral-500 rounded-l-xs border border-neutral-900" />
      </div>

      {/* Bottom Leaf Half */}
      <div className="relative h-1/2 w-full overflow-hidden rounded-b-sm border border-border/80 bg-neutral-950 shadow-inner flex items-start justify-center">
        <span
          className={cn(
            "leading-none -translate-y-[50%] block",
            colorClasses
          )}
        >
          {displayChar}
        </span>
        {/* Bottom edge shadow */}
        <div className="absolute inset-x-0 bottom-0 h-[2px] bg-black/50" />
      </div>

      {/* 3D Animated Flap Layer during transitions */}
      {isFlipping && (
        <div
          aria-hidden="true"
          className="absolute inset-0 z-30 pointer-events-none flex flex-col animate-[split-flap-drop_0.04s_linear]"
        >
          <div className="h-1/2 w-full overflow-hidden rounded-t-sm border border-border/80 bg-neutral-900 flex items-end justify-center opacity-90">
            <span className={cn("leading-none translate-y-[50%] block", colorClasses)}>
              {prevChar}
            </span>
          </div>
        </div>
      )}
    </div>
  );
}

/**
 * SplitFlapText: Renders a line of flipping split-flap characters.
 */
export function SplitFlapText({
  value,
  length,
  size = "md",
  color = "default",
  stagger = 30,
  sound = false,
  onComplete,
  className,
  ...props
}: SplitFlapTextProps) {
  const paddedValue = React.useMemo(() => {
    const raw = (value || "").toUpperCase();
    if (!length) return raw;
    return raw.padEnd(length, " ").slice(0, length);
  }, [value, length]);

  return (
    <div
      className={cn("inline-flex items-center gap-[2px]", className)}
      role="text"
      aria-label={value}
      {...props}
    >
      {paddedValue.split("").map((ch, idx) => (
        <SplitFlapChar
          key={idx}
          char={ch}
          size={size}
          color={color}
          sound={sound && idx === 0}
        />
      ))}
    </div>
  );
}

/**
 * SplitFlapRow: Departure schedule item with columns.
 */
export function SplitFlapRow({
  train,
  time,
  destination,
  track,
  status,
  remarks,
  sound = false,
  className,
  ...props
}: SplitFlapRowProps) {
  const statusColor = React.useMemo(() => {
    const s = status.toUpperCase();
    if (s.includes("ON TIME")) return "green";
    if (s.includes("BOARDING") || s.includes("FINAL")) return "amber";
    if (s.includes("DELAYED") || s.includes("CANCEL")) return "destructive";
    return "default";
  }, [status]);

  return (
    <div
      className={cn(
        "grid grid-cols-12 gap-2 sm:gap-3 py-2.5 px-3 items-center border-b border-dashed border-border/70 hover:bg-muted/30 transition-colors",
        className
      )}
      {...props}
    >
      {/* Time */}
      <div className="col-span-2 sm:col-span-2">
        <SplitFlapText value={time} length={5} size="sm" sound={sound} />
      </div>

      {/* Train / Flight */}
      <div className="col-span-3 sm:col-span-2">
        <SplitFlapText value={train} length={6} size="sm" color="accent" />
      </div>

      {/* Destination */}
      <div className="col-span-4 sm:col-span-5 overflow-hidden">
        <SplitFlapText
          value={destination}
          length={16}
          size="sm"
          className="w-full truncate"
        />
      </div>

      {/* Track / Gate */}
      <div className="hidden sm:block sm:col-span-1">
        <SplitFlapText value={track} length={3} size="sm" />
      </div>

      {/* Status */}
      <div className="col-span-3 sm:col-span-2 flex items-center justify-end">
        <SplitFlapText
          value={status}
          length={9}
          size="sm"
          color={statusColor}
        />
      </div>
    </div>
  );
}

const DEFAULT_ROWS: SplitFlapRowData[] = [
  {
    id: "1",
    time: "08:45",
    train: "BB-102",
    destination: "NEW YORK PENN",
    track: "04",
    status: "ON TIME",
  },
  {
    id: "2",
    time: "09:15",
    train: "BB-204",
    destination: "BOSTON SOUTH",
    track: "08",
    status: "BOARDING",
  },
  {
    id: "3",
    time: "09:40",
    train: "BB-319",
    destination: "CHICAGO UNION",
    track: "02",
    status: "DELAYED",
  },
  {
    id: "4",
    time: "10:05",
    train: "BB-440",
    destination: "WASHINGTON DC",
    track: "11",
    status: "ON TIME",
  },
  {
    id: "5",
    time: "10:30",
    train: "BB-508",
    destination: "MONTREAL CENTR",
    track: "06",
    status: "FINAL CALL",
  },
];

/**
 * SplitFlapBoard: Full retro mechanical airport / station departure concourse board.
 */
export function SplitFlapBoard({
  title = "GRAND CENTRAL TERMINAL",
  subtitle = "DEPARTURES CONCOURSE · TRACK ASSIGNMENTS",
  terminalCode = "GCT-SOLARI-4",
  rows = DEFAULT_ROWS,
  sound = false,
  onSoundChange,
  onFlip,
  showControls = true,
  className,
  ...props
}: SplitFlapBoardProps) {
  const [boardRows, setBoardRows] = React.useState<SplitFlapRowData[]>(rows);
  const [soundActive, setSoundActive] = React.useState(sound);
  const [filter, setFilter] = React.useState<string>("ALL");
  const [clockTime, setClockTime] = React.useState("08:42:15");

  // Keep internal sound synced if controlled
  React.useEffect(() => {
    setSoundActive(sound);
  }, [sound]);

  // Update clock every second
  React.useEffect(() => {
    const timer = setInterval(() => {
      const d = new Date();
      const hh = String(d.getHours()).padStart(2, "0");
      const mm = String(d.getMinutes()).padStart(2, "0");
      const ss = String(d.getSeconds()).padStart(2, "0");
      setClockTime(`${hh}:${mm}:${ss}`);
    }, 1000);
    return () => clearInterval(timer);
  }, []);

  const handleToggleSound = () => {
    const next = !soundActive;
    setSoundActive(next);
    if (onSoundChange) {
      onSoundChange(next);
    }
  };

  const handleShuffle = () => {
    const shuffled = [...boardRows].map((r) => {
      const statuses: SplitFlapRowData["status"][] = [
        "ON TIME",
        "BOARDING",
        "DELAYED",
        "FINAL CALL",
        "ON TIME",
      ];
      const randomStatus = statuses[Math.floor(Math.random() * statuses.length)];
      return {
        ...r,
        status: randomStatus,
      };
    });
    setBoardRows(shuffled);
  };

  const filteredRows = React.useMemo(() => {
    if (filter === "ALL") return boardRows;
    return boardRows.filter((r) => r.status.toUpperCase().includes(filter));
  }, [boardRows, filter]);

  return (
    <div
      className={cn(
        "relative w-full rounded-xl border-2 border-foreground bg-card text-card-foreground shadow-[6px_6px_0_0_var(--color-border)] outline-1 outline-dashed outline-offset-[-6px] overflow-hidden select-none",
        className
      )}
      {...props}
    >
      {/* Side Punch Notches */}
      <div
        aria-hidden="true"
        className="absolute -left-3 top-1/2 -translate-y-1/2 size-6 rounded-full border-2 border-foreground bg-background z-30"
      />
      <div
        aria-hidden="true"
        className="absolute -right-3 top-1/2 -translate-y-1/2 size-6 rounded-full border-2 border-foreground bg-background z-30"
      />

      {/* Terminal Header Plate */}
      <div className="border-b-2 border-dashed border-border bg-secondary/60 p-4 sm:p-5">
        <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
          <div>
            <div className="flex items-center gap-2">
              <span className="inline-block size-2 rounded-full bg-emerald-500 animate-ping" />
              <span className="font-mono text-[10px] font-bold tracking-[0.25em] text-accent uppercase">
                MECHANICAL DEPARTURE TERMINAL
              </span>
              <span className="font-mono text-[10px] text-muted-foreground">
                [{terminalCode}]
              </span>
            </div>
            <h2 className="mt-1 text-xl sm:text-2xl font-black tracking-wider uppercase font-mono text-foreground">
              {title}
            </h2>
            <p className="font-mono text-xs text-muted-foreground uppercase tracking-wider">
              {subtitle}
            </p>
          </div>

          {/* Clock & Status Indicators */}
          <div className="flex items-center gap-3 sm:gap-4 self-start sm:self-auto">
            <div className="rounded border border-border bg-neutral-950 px-3 py-1.5 font-mono text-xs text-amber-400 font-bold tracking-widest shadow-inner">
              <span className="text-[10px] text-muted-foreground block -mb-0.5">LOCAL CLOCK</span>
              {clockTime}
            </div>

            {showControls && (
              <div className="flex items-center gap-1.5">
                <button
                  type="button"
                  onClick={handleToggleSound}
                  className={cn(
                    "font-mono text-[10px] uppercase font-bold px-2.5 py-1.5 rounded border transition-colors focus-visible:ring-2 ring-ring",
                    soundActive
                      ? "border-accent bg-accent text-accent-foreground"
                      : "border-border bg-card text-muted-foreground hover:text-foreground"
                  )}
                  title="Toggle mechanical Solari relay click sounds"
                >
                  {soundActive ? "AUDIO ON" : "AUDIO MUTED"}
                </button>

                <button
                  type="button"
                  onClick={handleShuffle}
                  className="font-mono text-[10px] uppercase font-bold px-2.5 py-1.5 rounded border border-foreground bg-card text-foreground hover:bg-secondary transition-colors focus-visible:ring-2 ring-ring shadow-[2px_2px_0_0_var(--color-border)]"
                >
                  CYCLE FLAPS
                </button>
              </div>
            )}
          </div>
        </div>

        {/* Filter Pills */}
        {showControls && (
          <div className="mt-3.5 pt-3 border-t border-dashed border-border/60 flex items-center gap-1.5 overflow-x-auto">
            <span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground me-1">
              FILTER:
            </span>
            {["ALL", "ON TIME", "BOARDING", "DELAYED"].map((f) => (
              <button
                key={f}
                type="button"
                onClick={() => setFilter(f)}
                className={cn(
                  "font-mono text-[10px] uppercase tracking-wider px-2 py-0.5 rounded border transition-all",
                  filter === f
                    ? "border-foreground bg-foreground text-background font-bold"
                    : "border-border bg-card text-muted-foreground hover:text-foreground"
                )}
              >
                {f}
              </button>
            ))}
          </div>
        )}
      </div>

      {/* Board Column Headers */}
      <div className="grid grid-cols-12 gap-2 sm:gap-3 py-2 px-3 bg-neutral-900 text-neutral-300 font-mono text-[10px] font-bold uppercase tracking-[0.18em] border-b-2 border-foreground">
        <div className="col-span-2 sm:col-span-2">TIME</div>
        <div className="col-span-3 sm:col-span-2">TRAIN/FLT</div>
        <div className="col-span-4 sm:col-span-5">DESTINATION</div>
        <div className="hidden sm:block sm:col-span-1">TRACK</div>
        <div className="col-span-3 sm:col-span-2 text-right">STATUS</div>
      </div>

      {/* Flap Rows Container */}
      <div className="bg-neutral-950/95 divide-y divide-border/20">
        {filteredRows.length === 0 ? (
          <div className="p-8 text-center font-mono text-xs text-muted-foreground uppercase tracking-widest">
            NO DEPARTURES FOUND FOR THIS FILTER SELECTION
          </div>
        ) : (
          filteredRows.map((r, i) => (
            <SplitFlapRow
              key={r.id || `${r.train}-${i}`}
              train={r.train}
              time={r.time}
              destination={r.destination}
              track={r.track}
              status={r.status}
              remarks={r.remarks}
              sound={soundActive && i === 0}
            />
          ))
        )}
      </div>

      {/* Terminal Footer Stub */}
      <div className="p-3 bg-secondary/50 border-t-2 border-dashed border-border flex flex-col sm:flex-row items-center justify-between gap-2 text-center sm:text-start">
        <div className="font-mono text-[10px] tracking-wider uppercase text-muted-foreground">
          ★ SOLARI PROTOCOL V4.2 · TICKETS MUST BE VALIDATED AT GATE ENTRANCE ★
        </div>
        <div className="font-mono text-[9px] font-bold uppercase text-accent tracking-[0.2em]">
          PERFORATION CODE #GCT-9910
        </div>
      </div>
    </div>
  );
}

Props

PropTypeDescription
char?string
size?"sm" | "md" | "lg"
color?"default" | "accent" | "amber" | "green" | "destructive" | "muted"
sound?boolean
onFlip?() => void
className?string