Tube & Rail Arrivals
Departures at a Tube or rail station, grouped by line and bound.
Preview
Usage
Installation
pnpm dlx shadcn@latest add https://tfl.manglekuo.com/r/rail-arrivals-board.jsonDepartures at a Tube or rail station, grouped by line and bound.
pnpm dlx shadcn@latest add https://tfl.manglekuo.com/r/rail-arrivals-board.jsonPass RealtimePrediction[] from tfl.stopPoint.getArrivals as data. Polling stays outside the board.
import TflClient from "tfl-ts"
const tfl = new TflClient({
appKey: process.env.TFL_APP_KEY!,
})
const data = await tfl.stopPoint.getArrivals({
stopPointIds: ["940GZZLUOXC"],
sortBy: "timeToStation",
})getArrivals returns RealtimePrediction[]. Pass that array straight through. The board sorts it.
Elizabeth line and Overground often send towards: "". Use getNormalizedArrivals from tfl-ts 2.7, or keep getArrivals and fall through to destinationName the way this board does.
A physical interchange is often more than one StopPoint. Liverpool Street's Underground id (940GZZLULVT) never returns Elizabeth line or Weaver predictions. Those live on the rail sibling (910GLIVST). Polling the interchange hub id (HUBLST) returns nothing.
Resolve the ids that actually carry the lines you want, then fetch those:
import { STATION_HUBS, resolveArrivalsStopIds } from "tfl-ts"
const hub = STATION_HUBS["940GZZLULVT"]
const stopPointIds = hub
? resolveArrivalsStopIds(hub, ["central", "elizabeth", "weaver"])
: ["940GZZLULVT"]
const data = await tfl.stopPoint.getArrivals({
stopPointIds,
sortBy: "timeToStation",
})resolveArrivalsStopId(hub, lineId) is the per-line form. It returns undefined when that hub does not carry the line. Do not poll National Rail operator ids (c2c, South Western Railway). TfL's Arrivals API has no live predictions for them.
On Circle / Hammersmith & City / Metropolitan shared track, TfL assigns lineId per station. The same train can be Circle at Victoria and H&C at Liverpool Street. tfl-ts withSharedTrackIdentity tags a stable canonicalLineId from exclusive-segment topology when the evidence is there. Vehicles TfL lists on two or more of those lines with no exclusive hit stay uncanonical and carry rawLineIds for a stacked codes chip. This site's Board opts in at every shared-track station; a library install calls the helper in the app.
This site's Board builder expands siblings for you. A library install still has to do it in the app, because the board only renders the rows you pass in.
Bus has its own board, BusArrivalsBoard. The two share row chrome, chips, and the tile rhythm, not the high-level API. Fetching, caching, and polling stay in the app.
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 { RailArrivalsBoard } from "@/components/tfl/arrivals/rail-arrivals-board"
export default function Page({ data }) {
return (
<RailArrivalsBoard
data={data}
stopName="Oxford Circus"
lines={[
{ lineId: "bakerloo", lineName: "Bakerloo", bounds: ["northbound", "southbound"] },
{ lineId: "central", lineName: "Central", bounds: ["westbound", "eastbound"] },
{ lineId: "victoria", lineName: "Victoria", bounds: ["northbound", "southbound"] },
]}
/>
)
}Groups by line, then by compass bound from platformName. Default order is West above East and North above South — the live preview at the top of this page. Bound labels match the line-name size. When direction and platform are 1:1, the heading is Eastbound · Platform 1 and the row does not repeat a platform chip. When a line has no compass prefix, as on Elizabeth line and Overground, the board groups by platform and spells the heading out (Platform A). Destinations use StationName. Hairlines inside a line group share one weight, including under bound titles. Literal Platform Unknown becomes a Platform to be confirmed subgroup — it is not a blank heading and not a chip.
Line sections stay in canonical LINE_ORDER. Empty expected lines keep that slot. Pass optional lines for the stop's serving set so a line with no predictions still renders a muted No information row. When lines[].bounds lists the compass sides that station actually serves, those bound groups still render even if the current poll has no trains that way. Otherwise a missing side is omitted.
Each bound shows pageSize trains, default 3. Override per line with pageSizeByLine (ID-keyed map; falls back to pageSize). Extra pages sit in a native horizontal scroll-snap track. On hover-capable pointers, hover the bound label and its rows for quiet arrows with a 1/5 count — clicks jump instantly. On touch screens, swipe the bound rows; half-opacity page dots stay visible in place of the arrows. A short last page still occupies pageSize rows so paging does not jump the layout. A bound with only one page stays short and hides the pager.
Pass lineOrder to pin section order by line id. Listed lines rank by list position; unlisted serving lines follow, still in canonical order among themselves. Ordering only — it does not hide lines or seed phantom sections.
A completely flat rail board is a different product. It would need a compressed row that still shows line, bound, platform, destination, and countdown, not a layout switch on this component.
Pass lineGroups to merge lines that share platforms. Liverpool Street is the usual example: Circle, Hammersmith & City, and Metropolitan become one section. Central stays its own section — those platforms are separate, so those rows have no line chip.
The merged header uses the Line title Shared-track group treatment: foreground LineName plus a segmented brand bar, not a single line colour. Mixed-line rows lead with a line chip — a single LineBadge when the train has one identity, or LineBadgeGroup variant="codes" when TfL lists it on two or three lines. Both chips use a fixed 5ch box, same as the bus route chip, so CIR, H&C, MET, and the stacked codes chip line up. The component does not infer merges from co-occurring line ids. This site’s Board derives the table from shared-track topology (Baker Street excludes Metropolitan); a library install passes lineGroups from the app.
<RailArrivalsBoard
data={data}
stopName="Liverpool Street"
lines={lines}
lineGroups={[
{ lines: ["circle", "hammersmith-city", "metropolitan"] },
]}
pageSizeByLine={{
circle: 6,
"hammersmith-city": 6,
metropolitan: 6,
}}
/>Merged sections default to twice the usual pageSize (3 → 6) so the first page is not three trains to the same destination. Tag arrivals with tfl-ts withSharedTrackIdentity in the app if you want remapped or stacked-code rows.
The default is one vertical stack at every width — the live preview at the top of this page. Arrangement is CSS. The board root is a container named arrivals, and each line section is a container named arrivals-group. Pass className for the root and classNames for the generated levels. No JavaScript layout config, no resize listeners, no extra wrappers.
classNames key | data-slot | Level |
|---|---|---|
arrivals-board | Board root, the className prop | |
groups | arrivals-groups | All line sections |
group | arrivals-group | One line section, also a container named arrivals-group |
subgroups | arrivals-subgroups | The bounds list inside one line |
subgroup | arrivals-subgroup | One bound, label tile plus rows |
rows | arrivals-rows | One bound's arrival rows |
arrivals-row | Each arrival or empty-state row tile |
Bound columns must respond to their own line section, so query /arrivals-group on subgroups, not /arrivals. A wide board of narrow line columns keeps bounds stacked. Below your breakpoints every arrangement returns to the single-column stack. The fixed tile rhythm is unchanged.
Three lines side by side once the board itself is wide enough.
<RailArrivalsBoard
data={data}
stopName="Oxford Circus"
lines={lines}
classNames={{
groups: "@min-[52rem]/arrivals:grid-cols-3 @min-[52rem]/arrivals:gap-x-6",
}}
/>Lines stay stacked. A line wide enough places Westbound beside Eastbound. The query is against /arrivals-group.
<RailArrivalsBoard
data={data}
stopName="Oxford Circus"
lines={lines}
classNames={{
subgroups:
"@min-[30rem]/arrivals-group:grid-cols-2 @min-[30rem]/arrivals-group:gap-x-6",
}}
/>Flex-wrap arranges the lines. Each line then decides on its own whether its bounds fit side by side. A line sharing a row stays narrow and stacks its bounds. A line alone on a row grows wide enough to columnise them.
<RailArrivalsBoard
data={data}
stopName="Oxford Circus"
lines={lines}
classNames={{
groups:
"@min-[42rem]/arrivals:flex @min-[42rem]/arrivals:flex-wrap @min-[42rem]/arrivals:gap-x-6",
group: "@min-[42rem]/arrivals:grow @min-[42rem]/arrivals:basis-[24rem]",
subgroups:
"@min-[40rem]/arrivals-group:grid-cols-2 @min-[40rem]/arrivals-group:gap-x-4",
}}
/>Named policies, not comparator callbacks:
| Prop | Default | "source" |
|---|---|---|
sortBy | timeToStation within each bound | Input order within each bound |
lineSortBy | canonical (LINE_ORDER) | First appearance in data, then unused lines |
boundSortBy | compass (West→East, North→South) | First appearance within the line |
maxRows is a per-bound cap after that order. It does not drop later lines. pageSize is how many trains each bound shows at once; pageSizeByLine overrides that per line id. Equal timeToStation keeps input order. The board does not mutate data.
lineOrder overrides lineSortBy when set: listed ids keep their list position, everything else follows in canonical order.
Rail and bus share one vertical rhythm, so two boards side by side line up. Every block is a whole number of --arrivals-unit (0.5rem): the stop name, each line header, bound label, and arrival row are 6 units (3rem). Retune density by overriding the vars on a wrapper:
<div style={{ "--arrivals-unit": "0.375rem" }}>
<RailArrivalsBoard data={data} stopName="Oxford Circus" />
</div>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. |
lines | readonly { lineId, lineName, modeName?, bounds? }[] | Optional serving lines. Empty lines keep a "No information" row in canonical order. Optional bounds seeds West/East/North/South groups from station metadata. |
sortBy | "timeToStation" | "source" | Arrival order within each bound. |
lineSortBy | "canonical" | "source" | Line section order. |
lineOrder | readonly string[] | Explicit line section order by id. Listed lines first; unlisted follow canonically. Ordering only — does not hide or seed lines. Overrides lineSortBy. |
boundSortBy | "compass" | "source" | Bound order within a line. |
stopName | string | Optional heading override. Omit to use data[].stationName. Fits via abbr/scale, same policy as destinations. |
loading / error | … | Loading and error presentation. Prefer a short human error string. |
emptyKind | "empty" | "ended" | "offline" | Why the board has no sections when error is unset and lines is empty. Resolve in the app (resolveArrivalsEmptyKind). |
emptyMessage | string | Optional copy override for emptyKind. |
statusLabel | string | Optional short caption in the title row. |
maxRows | number | Per-bound prediction cap after ordering. Does not drop later lines. Headers and empty rows do not count. |
pageSize | number | Visible arrivals per bound, default 3. Extra trains page via hover arrows or touch swipe. Hidden when only one page. |
pageSizeByLine | Readonly<Record<string, number>> | ID-keyed rows-per-bound override. Falls back to pageSize. Each value applies to every bound on that line. |
className | string | Root classes, merged over the board container. |
classNames | { groups?, group?, subgroups?, subgroup?, rows? } | Layout-level class overrides for generated data-slot levels. |
Pass RealtimePrediction[] from tfl.stopPoint.getArrivals as data. Polling stays outside the board.
import TflClient from "tfl-ts"
const tfl = new TflClient({
appKey: process.env.TFL_APP_KEY!,
})
const data = await tfl.stopPoint.getArrivals({
stopPointIds: ["940GZZLUOXC"],
sortBy: "timeToStation",
})getArrivals returns RealtimePrediction[]. Pass that array straight through. The board sorts it.
Elizabeth line and Overground often send towards: "". Use getNormalizedArrivals from tfl-ts 2.7, or keep getArrivals and fall through to destinationName the way this board does.
A physical interchange is often more than one StopPoint. Liverpool Street's Underground id (940GZZLULVT) never returns Elizabeth line or Weaver predictions. Those live on the rail sibling (910GLIVST). Polling the interchange hub id (HUBLST) returns nothing.
Resolve the ids that actually carry the lines you want, then fetch those:
import { STATION_HUBS, resolveArrivalsStopIds } from "tfl-ts"
const hub = STATION_HUBS["940GZZLULVT"]
const stopPointIds = hub
? resolveArrivalsStopIds(hub, ["central", "elizabeth", "weaver"])
: ["940GZZLULVT"]
const data = await tfl.stopPoint.getArrivals({
stopPointIds,
sortBy: "timeToStation",
})resolveArrivalsStopId(hub, lineId) is the per-line form. It returns undefined when that hub does not carry the line. Do not poll National Rail operator ids (c2c, South Western Railway). TfL's Arrivals API has no live predictions for them.
On Circle / Hammersmith & City / Metropolitan shared track, TfL assigns lineId per station. The same train can be Circle at Victoria and H&C at Liverpool Street. tfl-ts withSharedTrackIdentity tags a stable canonicalLineId from exclusive-segment topology when the evidence is there. Vehicles TfL lists on two or more of those lines with no exclusive hit stay uncanonical and carry rawLineIds for a stacked codes chip. This site's Board opts in at every shared-track station; a library install calls the helper in the app.
This site's Board builder expands siblings for you. A library install still has to do it in the app, because the board only renders the rows you pass in.
Bus has its own board, BusArrivalsBoard. The two share row chrome, chips, and the tile rhythm, not the high-level API. Fetching, caching, and polling stay in the app.
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 { RailArrivalsBoard } from "@/components/tfl/arrivals/rail-arrivals-board"
export default function Page({ data }) {
return (
<RailArrivalsBoard
data={data}
stopName="Oxford Circus"
lines={[
{ lineId: "bakerloo", lineName: "Bakerloo", bounds: ["northbound", "southbound"] },
{ lineId: "central", lineName: "Central", bounds: ["westbound", "eastbound"] },
{ lineId: "victoria", lineName: "Victoria", bounds: ["northbound", "southbound"] },
]}
/>
)
}Groups by line, then by compass bound from platformName. Default order is West above East and North above South — the live preview at the top of this page. Bound labels match the line-name size. When direction and platform are 1:1, the heading is Eastbound · Platform 1 and the row does not repeat a platform chip. When a line has no compass prefix, as on Elizabeth line and Overground, the board groups by platform and spells the heading out (Platform A). Destinations use StationName. Hairlines inside a line group share one weight, including under bound titles. Literal Platform Unknown becomes a Platform to be confirmed subgroup — it is not a blank heading and not a chip.
Line sections stay in canonical LINE_ORDER. Empty expected lines keep that slot. Pass optional lines for the stop's serving set so a line with no predictions still renders a muted No information row. When lines[].bounds lists the compass sides that station actually serves, those bound groups still render even if the current poll has no trains that way. Otherwise a missing side is omitted.
Each bound shows pageSize trains, default 3. Override per line with pageSizeByLine (ID-keyed map; falls back to pageSize). Extra pages sit in a native horizontal scroll-snap track. On hover-capable pointers, hover the bound label and its rows for quiet arrows with a 1/5 count — clicks jump instantly. On touch screens, swipe the bound rows; half-opacity page dots stay visible in place of the arrows. A short last page still occupies pageSize rows so paging does not jump the layout. A bound with only one page stays short and hides the pager.
Pass lineOrder to pin section order by line id. Listed lines rank by list position; unlisted serving lines follow, still in canonical order among themselves. Ordering only — it does not hide lines or seed phantom sections.
A completely flat rail board is a different product. It would need a compressed row that still shows line, bound, platform, destination, and countdown, not a layout switch on this component.
Pass lineGroups to merge lines that share platforms. Liverpool Street is the usual example: Circle, Hammersmith & City, and Metropolitan become one section. Central stays its own section — those platforms are separate, so those rows have no line chip.
The merged header uses the Line title Shared-track group treatment: foreground LineName plus a segmented brand bar, not a single line colour. Mixed-line rows lead with a line chip — a single LineBadge when the train has one identity, or LineBadgeGroup variant="codes" when TfL lists it on two or three lines. Both chips use a fixed 5ch box, same as the bus route chip, so CIR, H&C, MET, and the stacked codes chip line up. The component does not infer merges from co-occurring line ids. This site’s Board derives the table from shared-track topology (Baker Street excludes Metropolitan); a library install passes lineGroups from the app.
<RailArrivalsBoard
data={data}
stopName="Liverpool Street"
lines={lines}
lineGroups={[
{ lines: ["circle", "hammersmith-city", "metropolitan"] },
]}
pageSizeByLine={{
circle: 6,
"hammersmith-city": 6,
metropolitan: 6,
}}
/>Merged sections default to twice the usual pageSize (3 → 6) so the first page is not three trains to the same destination. Tag arrivals with tfl-ts withSharedTrackIdentity in the app if you want remapped or stacked-code rows.
The default is one vertical stack at every width — the live preview at the top of this page. Arrangement is CSS. The board root is a container named arrivals, and each line section is a container named arrivals-group. Pass className for the root and classNames for the generated levels. No JavaScript layout config, no resize listeners, no extra wrappers.
classNames key | data-slot | Level |
|---|---|---|
arrivals-board | Board root, the className prop | |
groups | arrivals-groups | All line sections |
group | arrivals-group | One line section, also a container named arrivals-group |
subgroups | arrivals-subgroups | The bounds list inside one line |
subgroup | arrivals-subgroup | One bound, label tile plus rows |
rows | arrivals-rows | One bound's arrival rows |
arrivals-row | Each arrival or empty-state row tile |
Bound columns must respond to their own line section, so query /arrivals-group on subgroups, not /arrivals. A wide board of narrow line columns keeps bounds stacked. Below your breakpoints every arrangement returns to the single-column stack. The fixed tile rhythm is unchanged.
Three lines side by side once the board itself is wide enough.
<RailArrivalsBoard
data={data}
stopName="Oxford Circus"
lines={lines}
classNames={{
groups: "@min-[52rem]/arrivals:grid-cols-3 @min-[52rem]/arrivals:gap-x-6",
}}
/>Lines stay stacked. A line wide enough places Westbound beside Eastbound. The query is against /arrivals-group.
<RailArrivalsBoard
data={data}
stopName="Oxford Circus"
lines={lines}
classNames={{
subgroups:
"@min-[30rem]/arrivals-group:grid-cols-2 @min-[30rem]/arrivals-group:gap-x-6",
}}
/>Flex-wrap arranges the lines. Each line then decides on its own whether its bounds fit side by side. A line sharing a row stays narrow and stacks its bounds. A line alone on a row grows wide enough to columnise them.
<RailArrivalsBoard
data={data}
stopName="Oxford Circus"
lines={lines}
classNames={{
groups:
"@min-[42rem]/arrivals:flex @min-[42rem]/arrivals:flex-wrap @min-[42rem]/arrivals:gap-x-6",
group: "@min-[42rem]/arrivals:grow @min-[42rem]/arrivals:basis-[24rem]",
subgroups:
"@min-[40rem]/arrivals-group:grid-cols-2 @min-[40rem]/arrivals-group:gap-x-4",
}}
/>Named policies, not comparator callbacks:
| Prop | Default | "source" |
|---|---|---|
sortBy | timeToStation within each bound | Input order within each bound |
lineSortBy | canonical (LINE_ORDER) | First appearance in data, then unused lines |
boundSortBy | compass (West→East, North→South) | First appearance within the line |
maxRows is a per-bound cap after that order. It does not drop later lines. pageSize is how many trains each bound shows at once; pageSizeByLine overrides that per line id. Equal timeToStation keeps input order. The board does not mutate data.
lineOrder overrides lineSortBy when set: listed ids keep their list position, everything else follows in canonical order.
Rail and bus share one vertical rhythm, so two boards side by side line up. Every block is a whole number of --arrivals-unit (0.5rem): the stop name, each line header, bound label, and arrival row are 6 units (3rem). Retune density by overriding the vars on a wrapper:
<div style={{ "--arrivals-unit": "0.375rem" }}>
<RailArrivalsBoard data={data} stopName="Oxford Circus" />
</div>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. |
lines | readonly { lineId, lineName, modeName?, bounds? }[] | Optional serving lines. Empty lines keep a "No information" row in canonical order. Optional bounds seeds West/East/North/South groups from station metadata. |
sortBy | "timeToStation" | "source" | Arrival order within each bound. |
lineSortBy | "canonical" | "source" | Line section order. |
lineOrder | readonly string[] | Explicit line section order by id. Listed lines first; unlisted follow canonically. Ordering only — does not hide or seed lines. Overrides lineSortBy. |
boundSortBy | "compass" | "source" | Bound order within a line. |
stopName | string | Optional heading override. Omit to use data[].stationName. Fits via abbr/scale, same policy as destinations. |
loading / error | … | Loading and error presentation. Prefer a short human error string. |
emptyKind | "empty" | "ended" | "offline" | Why the board has no sections when error is unset and lines is empty. Resolve in the app (resolveArrivalsEmptyKind). |
emptyMessage | string | Optional copy override for emptyKind. |
statusLabel | string | Optional short caption in the title row. |
maxRows | number | Per-bound prediction cap after ordering. Does not drop later lines. Headers and empty rows do not count. |
pageSize | number | Visible arrivals per bound, default 3. Extra trains page via hover arrows or touch swipe. Hidden when only one page. |
pageSizeByLine | Readonly<Record<string, number>> | ID-keyed rows-per-bound override. Falls back to pageSize. Each value applies to every bound on that line. |
className | string | Root classes, merged over the board container. |
classNames | { groups?, group?, subgroups?, subgroup?, rows? } | Layout-level class overrides for generated data-slot levels. |