Transit Departures
A live departure board: minutes count down, due trains blink once and leave, lines filter, and a stop can be starred.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/transit-departures.json'use client'
import { useEffect, useState } from 'react'
import './transit-departures.css'
export interface Departure { id: string; line: string; to: string; in: number; delay?: number; platform?: string }
export interface TransitDeparturesProps {
departures: Departure[]
stop?: string
show?: 'min' | 'clock'
now?: number
fill?: 'dark' | 'light'
className?: string
}
/** Live departure board with countdown and line filter. */
export function TransitDepartures({ departures, stop = 'Central Station', show = 'min', now = Date.now(), fill = 'dark', className }: TransitDeparturesProps) {
const [left, setLeft] = useState(() => Object.fromEntries(departures.map((d) => [d.id, d.in])))
const [line, setLine] = useState<string | null>(null)
const [fav, setFav] = useState(false)
const [t, setT] = useState(0)
useEffect(() => { const i = setInterval(() => { setT((x) => x + 1); setLeft((l) => Object.fromEntries(Object.entries(l).map(([k, v]) => [k, v - 1]))) }, 1000); return () => clearInterval(i) }, [])
const lines = Array.from(new Set(departures.map((d) => d.line)))
const rows = departures.filter((d) => left[d.id] > -4 && (!line || d.line === line)).sort((a, b) => left[a.id] - left[b.id]).slice(0, 6)
const clock = (s: number) => new Date(now + (t + s) * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
return (
<div className={['sd-trn', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-trn__h">
<div><b>{stop}</b><small>Departures · {clock(0)}</small></div>
<button type="button" className="sd-trn__star" aria-pressed={fav} aria-label={fav ? 'Remove from favourites' : 'Add to favourites'} onClick={() => setFav(!fav)}>
<svg viewBox="0 0 20 20" aria-hidden><path d="m10 2.5 2.3 4.8 5.2.6-3.8 3.6 1 5.2L10 14.2l-4.7 2.5 1-5.2-3.8-3.6 5.2-.6z" /></svg>
</button>
</div>
<div className="sd-trn__chips" role="group" aria-label="Lines">
<button type="button" aria-pressed={!line} onClick={() => setLine(null)}>All</button>
{lines.map((l) => <button key={l} type="button" aria-pressed={line === l} onClick={() => setLine(line === l ? null : l)}>{l}</button>)}
</div>
<ul className="sd-trn__list" aria-label="Next departures">
{rows.map((d) => {
const s = left[d.id], due = s <= 30, m = Math.max(0, Math.ceil(s / 60))
return (
<li key={d.id} className="sd-trn__r" data-gone={s <= 0 || undefined} aria-label={`Line ${d.line} to ${d.to}, ${due ? 'due' : `${m} minutes`}`}>
<span className="sd-trn__ln" data-o={/^\d/.test(d.line) || undefined} aria-hidden>{d.line}</span>
<span className="sd-trn__d">{d.to}<small>{d.delay ? <><s>{clock(s - d.delay)}</s> {clock(s)} · +{Math.round(d.delay / 60)} min</> : <>{d.platform ? `Platform ${d.platform}` : 'On time'}</>}</small></span>
<span className="sd-trn__m" data-due={due || undefined} aria-hidden>{due ? 'Due' : show === 'clock' ? clock(s) : <>{m}<small>min</small></>}</span>
</li>
)
})}
{!rows.length && <li className="sd-trn__none">No more departures on this line</li>}
</ul>
</div>
)
}.sd-trn { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-trn[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-trn :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-trn :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-trn .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-trn { width: min(100%, 330px); border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-trn__h { display: flex; align-items: center; gap: 10px; padding: 14px 14px 10px 16px; }
.sd-trn__h div { flex: 1; min-width: 0; }
.sd-trn__h b { display: block; font-size: 15px; }
.sd-trn__h small { font-size: 12px; color: var(--mute); font-variant-numeric: tabular-nums; }
.sd-trn__star { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; }
.sd-trn__star:hover { background: var(--soft); }
.sd-trn__star svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linejoin: round; transition: transform 220ms cubic-bezier(0.3, 1.8, 0.4, 1); }
.sd-trn__star[aria-pressed='true'] svg { fill: currentColor; transform: scale(1.12); }
.sd-trn__chips { display: flex; gap: 6px; padding: 0 14px 10px; }
.sd-trn__chips button { min-width: 32px; height: 28px; padding: 0 10px; border-radius: 8px; font-size: 12.5px; font-weight: 700; box-shadow: inset 0 0 0 1px var(--line); color: var(--mute) !important; }
.sd-trn__chips button[aria-pressed='true'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-trn__list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); min-height: 260px; }
.sd-trn__r { display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 12px; padding: 11px 16px; border-bottom: 1px solid var(--line); animation: sd-trn-in 300ms ease-out; }
.sd-trn__r[data-gone] { animation: sd-trn-out 400ms ease-in forwards; }
.sd-trn__ln { display: grid; place-items: center; height: 26px; border-radius: 7px; background: var(--strong); color: var(--on); font-size: 13px; font-weight: 800; }
.sd-trn__ln[data-o] { background: transparent; color: var(--fg); box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-trn__d { min-width: 0; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-trn__d small { display: block; font-size: 12px; color: var(--mute); }
.sd-trn__d s { color: var(--mute); }
.sd-trn__m { text-align: right; font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }
.sd-trn__m small { font-size: 11px; font-weight: 500; color: var(--mute); margin-left: 2px; }
.sd-trn__m[data-due] { animation: sd-trn-blink 900ms steps(2, start) 2; font-size: 14px; }
.sd-trn__none { padding: 40px; text-align: center; color: var(--mute); font-size: 13px; }
@keyframes sd-trn-in { from { opacity: 0; transform: translateY(6px); } }
@keyframes sd-trn-out { to { opacity: 0; transform: translateX(-30px); } }
@keyframes sd-trn-blink { to { opacity: 0.2; } }
@media (prefers-reduced-motion: reduce) { .sd-trn *, .sd-trn *::before, .sd-trn *::after { animation: none !important; transition: none !important; } }Overview
Each row shows the line, destination and minutes until departure, counting down in real time. At zero the row reads “Due”, then leaves the board.
Filter by line with chips. Delays show the scheduled time struck out next to the new one.
When to use
- Transit apps, office lobby screens, event shuttles.
When not to use
- Static timetables: show the schedule.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/transit-departures.json$
npx shadcn@latest add https://sundaeui.dev/r/transit-departures.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/transit-departures.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/transit-departures.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy transit-departures.tsx and transit-departures.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { TransitDepartures } from '@/components/sundae/transit-departures'
<TransitDepartures />Props
| Prop | Type | Default |
|---|---|---|
departures*Line, destination, seconds until, delay. | Departure[] | — |
stopStop name. | string | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The board is a list; each row reads “Line 4 to Harbour, 3 minutes”.
- Due departures are announced politely, not every tick.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-trn; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Transit Departures free?
- Yes. Transit Departures is free for personal and commercial projects, with full source on this page.
- How do I install Transit Departures?
- Run npx shadcn@latest add https://sundaeui.dev/r/transit-departures.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Transit Departures support keyboard and screen readers?
- The board is a list; each row reads “Line 4 to Harbour, 3 minutes”. Due departures are announced politely, not every tick.
- Does Transit Departures respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All data →Countdown Timer
Data · RefinedStacked Bars
Data · Refined- Pro
Scrub Sparkline
Data · Refined - Pro
Tick Gauge
Data · Refined - Pro
Donut Breakdown
Data · Refined Savings Goal
Data · Refined