# Ferry Departure Board Rows Scramble Into Place

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/ferry-departure-board-rows-scramble-into-place).

- Page, with a live preview: https://shadcn.exchange/r/weinc/ferry-departure-board-rows-scramble-into-place
- Screenshot: https://shadcn.exchange/shot/weinc/ferry-departure-board-rows-scramble-into-place/light.png
- Registry item: https://we.inc/r/ferry-departure-board-rows-scramble-into-place.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/ferry-departure-board-rows-scramble-into-place.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Files

### `components/we-inc/ferry-departure-board-rows-scramble-into-place.tsx`

```tsx
import { useEffect, useRef } from "react";

const SAILINGS = [
  ["09:10", "Skerrow Isle", "Boarding"],
  ["09:45", "Port Annet", "On time"],
  ["10:20", "Holm Bay", "On time"],
  ["11:05", "Calder Quay", "Delayed"],
  ["11:40", "Linn Point", "On time"],
  ["12:15", "Easter Ness", "On time"],
];
const WIDTHS = [5, 12, 8]; // letter tiles per column
const ROWS = 4;
const GLYPHS = "ABCDEFGHKLMNPRSTVWXYZ0123456789";
const STEP = 40; // ms between one tile settling and the next
const LEAD = 220; // ms of noise before the first tile settles
const ADVANCE = 6000; // ms between automatic board updates until someone presses the button

type Field = { sr: HTMLElement; cells: HTMLElement[]; f: number };

export default function FerryDepartureBoardRowsScrambleIntoPlace() {
  const root = useRef<HTMLElement>(null);
  const advance = useRef<() => void>(() => {});

  useEffect(() => {
    const el = root.current;
    if (!el) return;
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const step = reduce ? 0 : STEP;
    const lead = reduce ? 0 : LEAD;
    const rows = Array.from(el.querySelectorAll("tbody tr"));
    const fields: Field[] = [];
    rows.forEach((tr) =>
      tr.querySelectorAll("td").forEach((td) =>
        fields.push({ sr: td.firstElementChild as HTMLElement, cells: Array.from(td.querySelectorAll<HTMLElement>(".w3dbd-tiles span")), f: 0 }),
      ),
    );
    let k = 0;
    let auto = 0;
    let live = false;
    let seen = false;
    const set = (fd: Field, text: string, delay: number) => {
      const to = (text + "            ").slice(0, fd.cells.length).toUpperCase();
      const t0 = performance.now() + delay;
      fd.sr.textContent = text;
      cancelAnimationFrame(fd.f);
      const frame = (now: number) => {
        const t = now - t0;
        let busy = false;
        fd.cells.forEach((c, i) => {
          if (c.textContent === to[i] && !c.hasAttribute("data-on")) return;
          if (t > lead + i * step) { c.textContent = to[i]; c.removeAttribute("data-on"); }
          else if (t > 0) { busy = true; c.setAttribute("data-on", ""); c.textContent = GLYPHS[(Math.random() * GLYPHS.length) | 0]; }
          else busy = true;
        });
        fd.f = busy ? requestAnimationFrame(frame) : 0;
      };
      frame(t0);
    };
    const show = () =>
      fields.forEach((fd, n) => {
        const row = (n / 3) | 0;
        set(fd, SAILINGS[(k + row) % SAILINGS.length][n % 3], row * 120 + (n % 3) * 60);
      });
    const next = () => { k = (k + 1) % SAILINGS.length; show(); };
    advance.current = () => { live = true; clearInterval(auto); next(); };
    const offs = rows.map((tr, row) => {
      const flip = () => {
        if (reduce) return;
        fields.slice(row * 3, row * 3 + 3).forEach((fd) => {
          fd.cells.forEach((c) => { c.textContent = ""; });
          set(fd, fd.sr.textContent || "", 0);
        });
      };
      tr.addEventListener("pointerenter", flip);
      return () => tr.removeEventListener("pointerenter", flip);
    });
    const io = new IntersectionObserver(([e]) => {
      clearInterval(auto);
      if (!e.isIntersecting) return;
      if (!seen) { seen = true; show(); }
      if (!live && !reduce) auto = window.setInterval(next, ADVANCE);
    });
    io.observe(el);
    return () => {
      io.disconnect();
      clearInterval(auto);
      offs.forEach((f) => f());
      fields.forEach((fd) => cancelAnimationFrame(fd.f));
    };
  }, []);

  return (
    <section ref={root} className="w3dbd bg-[#0e1a2b] px-5 py-[72px] text-[#e8edf4]">
      <style>{`
        .w3dbd { background: radial-gradient(120% 80% at 50% 0%, #17283f, #0e1a2b 70%); }
        .w3dbd-board { font-size: clamp(13px, 2.1vw, 19px); box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 24px 48px -24px rgba(0,0,0,.7); }
        .w3dbd td + td, .w3dbd th + th { padding-left: .9em; }
        @media (min-width: 641px) { .w3dbd td:not(:last-child), .w3dbd th:not(:last-child) { width: 1%; } }
        .w3dbd-tiles span { width: 1.3em; height: 1.75em; display: flex; align-items: center; justify-content: center; border-radius: 3px;
          background: linear-gradient(#22344f, #1a2a42 49%, #0c1626 50%, #1a2a42 52%); color: #f3e6c4;
          font: 700 1em/1 ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
        .w3dbd-tiles span[data-on] { color: #9fb2cc; }
        .w3dbd button:focus-visible { outline: 2px solid #e0b862; outline-offset: 3px; }
        @media (max-width: 640px) {
    .w3dbd-board { padding: 14px 12px; font-size: 14px; }
    .w3dbd thead tr { display: none; }
    .w3dbd tbody tr { display: grid; grid-template-columns: auto 1fr; column-gap: .9em; padding-bottom: 10px; }
    .w3dbd td:nth-child(2) { grid-column: 1 / 3; grid-row: 1; padding-left: 0; }
    .w3dbd td:last-child { padding-top: 0; }
  }
      `}</style>
      <div className="mx-auto max-w-4xl">
        <div className="flex flex-wrap items-end justify-between gap-4">
          <div>
            <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[#e0b862]">Harbour Line Ferries</p>
            <h2 className="mt-2.5 text-[clamp(26px,3.6vw,36px)] font-semibold tracking-tight">Departures from the north pier</h2>
          </div>
          <button
            type="button"
            onClick={() => advance.current()}
            className="min-h-[44px] rounded-full border border-[#3a4d6b] px-5 text-sm font-medium hover:bg-[#1a2a42]"
          >
            Later sailings
          </button>
        </div>
        <div className="w3dbd-board mt-7 rounded-[14px] bg-[#0a1422] p-[18px]">
          <table className="w-full border-collapse">
            <caption className="sr-only">Next four sailings</caption>
            <thead>
              <tr>
                {["Time", "Destination", "Status"].map((h) => (
                  <th key={h} scope="col" className="pb-2.5 text-left text-[11px] font-semibold uppercase tracking-[0.18em] text-[#93a3bb]">{h}</th>
                ))}
              </tr>
            </thead>
            <tbody>
              {Array.from({ length: ROWS }, (_, r) => (
                <tr key={r}>
                  {WIDTHS.map((w, c) => (
                    <td key={c} className="py-[5px] align-top">
                      <span className="sr-only">{SAILINGS[r][c]}</span>
                      <span className="w3dbd-tiles flex gap-[2px]" aria-hidden="true">
                        {Array.from({ length: w }, (_, i) => <span key={i} />)}
                      </span>
                    </td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
        <p className="mt-[22px] max-w-xl text-[15px] leading-relaxed text-[#a9b6c9]">
          Foot passengers board from gate A. Cars queue in lanes 1 to 4 from forty minutes before sailing.
        </p>
      </div>
    </section>
  );
}
```
