Flight Status
A flight card: route with a progress plane, gate and seat in large type, live delay or gate changes, and alerts.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/flight-status.json'use client'
import { useEffect, useState } from 'react'
import './flight-status.css'
export interface FlightInfo { code: string; from: string; to: string; fromCity: string; toCity: string; dep: string; arr: string; gate: string; seat: string; group: string }
export interface FlightEvent { at: number; kind: 'delay' | 'gate' | 'boarding' | 'departed' | 'landed'; value?: string }
export interface FlightStatusProps {
flight: FlightInfo
events?: FlightEvent[]
fill?: 'dark' | 'light'
className?: string
}
const LABEL: Record<string, string> = { scheduled: 'On time', delayed: 'Delayed', boarding: 'Boarding', departed: 'In the air', landed: 'Landed' }
/** Live flight status card. */
export function FlightStatus({ flight, events = [], fill = 'dark', className }: FlightStatusProps) {
const [st, setSt] = useState<'scheduled' | 'delayed' | 'boarding' | 'departed' | 'landed'>('scheduled')
const [dep, setDep] = useState<string | null>(null)
const [gate, setGate] = useState(flight.gate)
const [changed, setChanged] = useState<string | null>(null)
const [log, setLog] = useState<string[]>([])
const [alerts, setAlerts] = useState(true)
const [prog, setProg] = useState(0)
useEffect(() => {
const ts = events.map((e) => setTimeout(() => {
if (e.kind === 'delay') { setSt('delayed'); setDep(e.value!); setChanged('dep'); setLog((l) => [`Delayed to ${e.value}`, ...l]) }
if (e.kind === 'gate') { setGate(e.value!); setChanged('gate'); setLog((l) => [`Gate changed to ${e.value}`, ...l]) }
if (e.kind === 'boarding') { setSt('boarding'); setChanged(null); setLog((l) => [`Boarding group ${flight.group}`, ...l]) }
if (e.kind === 'departed') { setSt('departed'); setProg(0.45); setLog((l) => ['Departed', ...l]) }
if (e.kind === 'landed') { setSt('landed'); setProg(1); setLog((l) => ['Landed', ...l]) }
}, e.at))
return () => ts.forEach(clearTimeout)
}, [events, flight.group])
return (
<div className={['sd-flt', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-flt__top"><span><b>{flight.code}</b> · Wed 4 Nov</span><span className="sd-flt__st" data-k={st} aria-live="polite">{LABEL[st]}</span></div>
<div className="sd-flt__rt">
<div className="sd-flt__ap"><b>{flight.from}</b><span>{dep ? <><s>{flight.dep}</s><em>{dep}</em></> : flight.dep}</span></div>
<div className="sd-flt__ln" aria-hidden><i style={{ width: `${prog * 100}%` }} /><svg viewBox="0 0 24 24" style={{ left: `${prog * 100}%` }}><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5z" transform="rotate(90 12 12)" /></svg></div>
<div className="sd-flt__ap"><b>{flight.to}</b><span>{flight.arr}</span></div>
</div>
<div className="sd-flt__big">
<div data-changed={changed === 'gate' || undefined}><small>Gate</small><b>{gate}</b></div>
<div><small>Seat</small><b>{flight.seat}</b></div>
<div><small>Group</small><b>{flight.group}</b></div>
</div>
<ul className="sd-flt__log" aria-label="Updates">{log.length ? log.slice(0, 3).map((l, i) => <li key={log.length - i}>{i === 0 ? <b>{l}</b> : l}</li>) : <li>{flight.fromCity} to {flight.toCity}. Updates appear here.</li>}</ul>
<div className="sd-flt__f"><span>Push alerts for this flight</span><button type="button" role="switch" aria-checked={alerts} aria-label="Push alerts for this flight" className="sd-flt__sw" onClick={() => setAlerts((a) => !a)} /></div>
</div>
)
}.sd-flt { --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-flt[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-flt :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-flt :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-flt .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-flt { width: min(100%, 340px); padding: 16px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 14px; }
.sd-flt__top { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: var(--mute); }
.sd-flt__top b { color: var(--fg); font-size: 13px; }
.sd-flt__st { padding: 3px 9px; border-radius: 99px; font-size: 11.5px; font-weight: 700; box-shadow: inset 0 0 0 1.5px var(--fg); color: var(--fg); }
.sd-flt__st[data-k='boarding'], .sd-flt__st[data-k='departed'], .sd-flt__st[data-k='landed'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-flt__rt { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; }
.sd-flt__ap b { display: block; font-size: 30px; font-weight: 700; letter-spacing: -0.02em; line-height: 1; }
.sd-flt__ap span { font-size: 12px; color: var(--mute); font-variant-numeric: tabular-nums; }
.sd-flt__ap span s { margin-right: 4px; }
.sd-flt__ap span em { font-style: normal; color: var(--fg); font-weight: 700; }
.sd-flt__ap:last-child { text-align: right; }
.sd-flt__ln { position: relative; height: 20px; }
.sd-flt__ln::before { content: ''; position: absolute; left: 0; right: 0; top: 50%; border-top: 1.5px dashed var(--line); }
.sd-flt__ln i { position: absolute; left: 0; top: 50%; height: 0; border-top: 1.5px solid var(--fg); transition: width 600ms; }
.sd-flt__ln svg { position: absolute; top: 50%; width: 18px; height: 18px; margin: -9px 0 0 -9px; fill: var(--fg); transition: left 600ms; }
.sd-flt__big { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.sd-flt__big div { padding: 10px; border-radius: 12px; background: var(--soft); }
.sd-flt__big small { display: block; font-size: 11px; color: var(--mute); }
.sd-flt__big b { font-size: 22px; font-weight: 700; letter-spacing: -0.01em; }
.sd-flt__big div[data-changed] { box-shadow: inset 0 0 0 2px var(--fg); animation: sd-flt-p 600ms ease-out; }
@keyframes sd-flt-p { 30% { transform: scale(1.04); } }
.sd-flt__log { margin: 0; padding: 0; list-style: none; display: grid; gap: 4px; font-size: 12px; color: var(--mute); min-height: 40px; }
.sd-flt__log li b { color: var(--fg); }
.sd-flt__f { display: flex; align-items: center; justify-content: space-between; font-size: 12.5px; }
.sd-flt__sw { position: relative; width: 38px; height: 22px; border-radius: 99px; background: var(--line) !important; }
.sd-flt__sw::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: var(--fg); transition: transform 180ms; }
.sd-flt__sw[aria-checked='true'] { background: var(--fg) !important; }
.sd-flt__sw[aria-checked='true']::after { transform: translateX(16px); background: var(--bg); }
@media (prefers-reduced-motion: reduce) { .sd-flt *, .sd-flt *::before, .sd-flt *::after { animation: none !important; transition: none !important; } }Overview
The header shows the route as airport codes with local times. The status line moves through scheduled, boarding, departed and landed; delays show the new time beside the old one struck out.
Gate and seat are the biggest text on the card because that is what people look for in a terminal. Alerts can be toggled per flight.
When to use
- Travel apps, booking confirmations, day-of-travel screens.
When not to use
- Multi-leg itineraries: list each leg.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/flight-status.json$
npx shadcn@latest add https://sundaeui.dev/r/flight-status.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/flight-status.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/flight-status.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 flight-status.tsx and flight-status.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 { FlightStatus } from '@/components/sundae/flight-status'
<FlightStatus />Props
| Prop | Type | Default |
|---|---|---|
flight*Flight. | { code: string; from: string; to: string; dep: string; arr: string; gate: string; seat: string } | — |
eventsStatus changes for the demo. | { at: number; kind: string; value?: string }[] | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Status changes are announced politely; the route graphic is decorative.
- The alerts switch is labelled.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-flt; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Flight Status free?
- Yes. Flight Status is free for personal and commercial projects, with full source on this page.
- How do I install Flight Status?
- Run npx shadcn@latest add https://sundaeui.dev/r/flight-status.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Flight Status support keyboard and screen readers?
- Status changes are announced politely; the route graphic is decorative. The alerts switch is labelled.
- Does Flight Status respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Order Tracker
Components · RefinedCI Checks
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined- Pro
Notification Center
Components · Refined