Bus Arrivals
Departures at a bus stop. Flat by default, or grouped by route.
Preview
G
Usage
Installation
pnpm dlx shadcn@latest add https://tfl.manglekuo.com/r/bus-arrivals-board.jsonDepartures at a bus stop. Flat by default, or grouped by route.
G
pnpm dlx shadcn@latest add https://tfl.manglekuo.com/r/bus-arrivals-board.jsonPass RealtimePrediction[] from tfl.stopPoint.getArrivals as data. The default board is a flat time-ordered list. Polling stays outside the board. For nearby / search, use Explorer → Bus stops.
G
import TflClient from "tfl-ts"
const tfl = new TflClient({
appKey: process.env.TFL_APP_KEY!,
})
const stopId = "490000091G"
const [stop, data] = await Promise.all([
tfl.stopPoint.get(stopId),
tfl.stopPoint.getArrivals({
stopPointIds: [stopId],
sortBy: "timeToStation",
}),
])get returns the stop (commonName, stopLetter). getArrivals returns RealtimePrediction[]. Pass both through. The board sorts arrivals itself.
Tube and rail use RailArrivalsBoard. The two share row chrome and the tile rhythm, not the high-level API. Fetching, caching, polling, and stop discovery stay in the app. Nearby search lives under Explorer → Bus stops.
On this docs site, the live preview polls with the site key until you paste a TfL API key in the docs sidebar. With your key set, the preview requests TfL from the browser against your quota. That browser key is not TFL_APP_KEY. Keep production keys on the server.
import { BusArrivalsBoard } from "@/components/tfl/arrivals/bus-arrivals-board"
<BusArrivalsBoard
data={data}
stopName={stop.commonName}
stopLetter={stop.stopLetter}
/>The default is flat: no route headers, no route colour bars, interleaved route numbers in soonest-first order.
Pass groupBy="route" when you want route sections. The homepage proof board does this. Routes sort in stable natural numeric order (9 before 18 before 205). Arrivals inside each route sort by timeToStation. Each section uses a bus-red bar and repeats the route chip on every row. Extra pages sit in a native horizontal scroll-snap track: hover for arrows with a page count on the route header, or swipe the rows on touch with half-opacity dots.
G
<BusArrivalsBoard
data={data}
stopName={stop.commonName}
stopLetter={stop.stopLetter}
groupBy="route"
/>getArrivals alone can't tell you a stop is in trouble — the raw prediction has no disruption field at all, and a fully-diverted route just vanishes from the list with no explanation. That signal lives in a separate call, stopPoint.getDisruption:
const disruptedPoints = await tfl.stopPoint.getDisruption({
stopPointIds: [stopId],
})Pass the result and your data through prepareBusStopDisruptions to get per-route warnings:
import { prepareBusStopDisruptions } from "@/lib/tfl/bus-stop-disruptions"
<BusArrivalsBoard
data={data}
disruptions={prepareBusStopDisruptions(disruptedPoints, data)}
stopName={stop.commonName}
stopLetter={stop.stopLetter}
/>The gotcha: checked against live TfL data, concernedLines is almost never populated for bus closures — the affected route, when named at all, is buried in the free-text description ("Route 164 is on diversion…"). prepareBusStopDisruptions parses that, and falls back to fanning a route-less "Bus Stop Closed" message out to every route currently in data — the whole physical stop is affected, not one route more than another.
The board paints one warning chip per disrupted route, inline after the stop name. Hover (desktop) or tap (touch) a chip to cover the rows below with that route's description; other chips dim while one is open.
Below, Capworth Street's stop-point disruption is a route-less "Bus Stop Closed" — so both 58 and 158 get the warning, even though TfL is still happily predicting arrivals for either one.
Same CSS model as rail: the board root is a container named arrivals, and each route section is a container named arrivals-group. Pass className for the root and classNames for generated levels.
classNames key | data-slot | Level |
|---|---|---|
| - | arrivals-board | Board root, the className prop |
groups | arrivals-groups | All route sections, grouped only |
group | arrivals-group | One route section, also a container named arrivals-group |
rows | arrivals-rows | One route's rows when grouped, or the whole list when flat |
| - | arrivals-row | Each arrival or empty-state row tile |
subgroups / subgroup are rail-only. Bus routes have no bound level. The flat board stays one time-ordered list at every width. Arrange columns on the grouped board only.
<BusArrivalsBoard
data={data}
stopName={stop.commonName}
stopLetter={stop.stopLetter}
groupBy="route"
classNames={{
groups: "@min-[40rem]/arrivals:grid-cols-2 @min-[40rem]/arrivals:gap-x-6",
}}
/><BusArrivalsBoard
data={data}
stopName={stop.commonName}
stopLetter={stop.stopLetter}
/>Named policies, not comparator callbacks:
| Prop | Default | "source" |
|---|---|---|
groupBy | none (flat list) | - |
sortBy | timeToStation | Input order, global when flat, per route when grouped |
groupSortBy | route (natural numeric) | First appearance in data |
groupSortBy only applies when groupBy="route". maxRows is a per-route cap when grouped, and a list cap when flat. pageSize pages extra arrivals via scroll-snap: hover arrows (with count) on the route header when grouped, or in a trailing tile when flat; touch uses swipe plus half-opacity dots. The pager hides when there is only one page. Equal timeToStation keeps input order. The board does not mutate data.
Same --arrivals-unit / --arrivals-row tiles as rail, so a bus board beside a rail board lines up. The stop letter is a red circle next to the stop name, not a per-row field.
Keep polling outside the board, then update data. Freshness and refresh belong in the app chrome, not on the board.
| Prop | Type | Description |
|---|---|---|
data | readonly RealtimePrediction[] | Normalised arrivals from tfl-ts. Missing or undefined renders empty. |
disruptions | readonly BusStopDisruption[] | Per-route warnings from prepareBusStopDisruptions. Renders a warning chip per route; hover/tap covers the rows with the description. |
groupBy | "none" | "route" | Flat list, default, or route sections. |
sortBy | "timeToStation" | "source" | Arrival order. |
groupSortBy | "route" | "source" | Route-group order when grouped. |
stopName | string | Optional heading override. Omit to use data[].stationName. Fits via abbr/scale. |
stopLetter | string | Stop letter, red circle next to the stop name. |
loading / error | … | Loading and error presentation. Prefer a short human error string. |
emptyKind | "empty" | "ended" | "offline" | Why the board has no rows when error is unset. Resolve in the app (resolveArrivalsEmptyKind). Night buses still run, so prefer empty over ended. |
emptyMessage | string | Optional copy override for emptyKind. |
statusLabel | string | Optional short caption in the title row. |
maxRows | number | Prediction cap after ordering. Per route when grouped, whole list when flat. Headers do not count. |
pageSize | number | Visible arrivals per page, default 3. Grouped: pager on the route header. Flat: trailing tile, right-aligned. Hover arrows / touch dots; hidden when only one page. |
className | string | Root classes, merged over the board container. |
classNames | { groups?, group?, rows? } | Layout-level class overrides for generated data-slot levels. subgroups / subgroup are rail-only. |
Pass RealtimePrediction[] from tfl.stopPoint.getArrivals as data. The default board is a flat time-ordered list. Polling stays outside the board. For nearby / search, use Explorer → Bus stops.
G
import TflClient from "tfl-ts"
const tfl = new TflClient({
appKey: process.env.TFL_APP_KEY!,
})
const stopId = "490000091G"
const [stop, data] = await Promise.all([
tfl.stopPoint.get(stopId),
tfl.stopPoint.getArrivals({
stopPointIds: [stopId],
sortBy: "timeToStation",
}),
])get returns the stop (commonName, stopLetter). getArrivals returns RealtimePrediction[]. Pass both through. The board sorts arrivals itself.
Tube and rail use RailArrivalsBoard. The two share row chrome and the tile rhythm, not the high-level API. Fetching, caching, polling, and stop discovery stay in the app. Nearby search lives under Explorer → Bus stops.
On this docs site, the live preview polls with the site key until you paste a TfL API key in the docs sidebar. With your key set, the preview requests TfL from the browser against your quota. That browser key is not TFL_APP_KEY. Keep production keys on the server.
import { BusArrivalsBoard } from "@/components/tfl/arrivals/bus-arrivals-board"
<BusArrivalsBoard
data={data}
stopName={stop.commonName}
stopLetter={stop.stopLetter}
/>The default is flat: no route headers, no route colour bars, interleaved route numbers in soonest-first order.
Pass groupBy="route" when you want route sections. The homepage proof board does this. Routes sort in stable natural numeric order (9 before 18 before 205). Arrivals inside each route sort by timeToStation. Each section uses a bus-red bar and repeats the route chip on every row. Extra pages sit in a native horizontal scroll-snap track: hover for arrows with a page count on the route header, or swipe the rows on touch with half-opacity dots.
G
<BusArrivalsBoard
data={data}
stopName={stop.commonName}
stopLetter={stop.stopLetter}
groupBy="route"
/>getArrivals alone can't tell you a stop is in trouble — the raw prediction has no disruption field at all, and a fully-diverted route just vanishes from the list with no explanation. That signal lives in a separate call, stopPoint.getDisruption:
const disruptedPoints = await tfl.stopPoint.getDisruption({
stopPointIds: [stopId],
})Pass the result and your data through prepareBusStopDisruptions to get per-route warnings:
import { prepareBusStopDisruptions } from "@/lib/tfl/bus-stop-disruptions"
<BusArrivalsBoard
data={data}
disruptions={prepareBusStopDisruptions(disruptedPoints, data)}
stopName={stop.commonName}
stopLetter={stop.stopLetter}
/>The gotcha: checked against live TfL data, concernedLines is almost never populated for bus closures — the affected route, when named at all, is buried in the free-text description ("Route 164 is on diversion…"). prepareBusStopDisruptions parses that, and falls back to fanning a route-less "Bus Stop Closed" message out to every route currently in data — the whole physical stop is affected, not one route more than another.
The board paints one warning chip per disrupted route, inline after the stop name. Hover (desktop) or tap (touch) a chip to cover the rows below with that route's description; other chips dim while one is open.
Below, Capworth Street's stop-point disruption is a route-less "Bus Stop Closed" — so both 58 and 158 get the warning, even though TfL is still happily predicting arrivals for either one.
Same CSS model as rail: the board root is a container named arrivals, and each route section is a container named arrivals-group. Pass className for the root and classNames for generated levels.
classNames key | data-slot | Level |
|---|---|---|
| - | arrivals-board | Board root, the className prop |
groups | arrivals-groups | All route sections, grouped only |
group | arrivals-group | One route section, also a container named arrivals-group |
rows | arrivals-rows | One route's rows when grouped, or the whole list when flat |
| - | arrivals-row | Each arrival or empty-state row tile |
subgroups / subgroup are rail-only. Bus routes have no bound level. The flat board stays one time-ordered list at every width. Arrange columns on the grouped board only.
<BusArrivalsBoard
data={data}
stopName={stop.commonName}
stopLetter={stop.stopLetter}
groupBy="route"
classNames={{
groups: "@min-[40rem]/arrivals:grid-cols-2 @min-[40rem]/arrivals:gap-x-6",
}}
/><BusArrivalsBoard
data={data}
stopName={stop.commonName}
stopLetter={stop.stopLetter}
/>Named policies, not comparator callbacks:
| Prop | Default | "source" |
|---|---|---|
groupBy | none (flat list) | - |
sortBy | timeToStation | Input order, global when flat, per route when grouped |
groupSortBy | route (natural numeric) | First appearance in data |
groupSortBy only applies when groupBy="route". maxRows is a per-route cap when grouped, and a list cap when flat. pageSize pages extra arrivals via scroll-snap: hover arrows (with count) on the route header when grouped, or in a trailing tile when flat; touch uses swipe plus half-opacity dots. The pager hides when there is only one page. Equal timeToStation keeps input order. The board does not mutate data.
Same --arrivals-unit / --arrivals-row tiles as rail, so a bus board beside a rail board lines up. The stop letter is a red circle next to the stop name, not a per-row field.
Keep polling outside the board, then update data. Freshness and refresh belong in the app chrome, not on the board.
| Prop | Type | Description |
|---|---|---|
data | readonly RealtimePrediction[] | Normalised arrivals from tfl-ts. Missing or undefined renders empty. |
disruptions | readonly BusStopDisruption[] | Per-route warnings from prepareBusStopDisruptions. Renders a warning chip per route; hover/tap covers the rows with the description. |
groupBy | "none" | "route" | Flat list, default, or route sections. |
sortBy | "timeToStation" | "source" | Arrival order. |
groupSortBy | "route" | "source" | Route-group order when grouped. |
stopName | string | Optional heading override. Omit to use data[].stationName. Fits via abbr/scale. |
stopLetter | string | Stop letter, red circle next to the stop name. |
loading / error | … | Loading and error presentation. Prefer a short human error string. |
emptyKind | "empty" | "ended" | "offline" | Why the board has no rows when error is unset. Resolve in the app (resolveArrivalsEmptyKind). Night buses still run, so prefer empty over ended. |
emptyMessage | string | Optional copy override for emptyKind. |
statusLabel | string | Optional short caption in the title row. |
maxRows | number | Prediction cap after ordering. Per route when grouped, whole list when flat. Headers do not count. |
pageSize | number | Visible arrivals per page, default 3. Grouped: pager on the route header. Flat: trailing tile, right-aligned. Hover arrows / touch dots; hidden when only one page. |
className | string | Root classes, merged over the board container. |
classNames | { groups?, group?, rows? } | Layout-level class overrides for generated data-slot levels. subgroups / subgroup are rail-only. |