Data Table
Sortable, selectable, paginated table with sticky header, bulk actions, empty and loading states.
Rows that open into rich detail panels with a smooth height animation, shown with a shipment route timeline.
Browser support · Animated height needs grid-template-rows interpolation (Chrome 107+, Safari 16+, Firefox 66+); older browsers snap open.
npx shadcn@latest add https://freedesignmd.com/r/expandable-table.json1/**2 * ExpandableTable — a table whose rows expand to reveal a detail panel. The3 * panel opens with a smooth, measured-free height animation using the4 * grid-template-rows 0fr → 1fr technique, so content of any height works.5 *6 * Props: data, columns, getRowId, renderDetail(row), caption, single (only7 * one row open at a time), expanded / onExpandedChange (controlled) or8 * defaultExpanded, theme.9 * Column: { key, header, align?, width?, render?, narrowHide? }10 *11 * Browser support: grid-template-rows interpolation needs Chrome 107, Safari12 * 16, Firefox 66; older browsers snap open without the animation.13 * Accessibility: every row has a disclosure button (aria-expanded,14 * aria-controls) in the first cell; the collapsed panel is visibility:hidden15 * so nothing inside it is focusable or announced; Enter / Space on the button16 * toggle; the whole row is also clickable for pointer users (ignored when the17 * click lands on an interactive child). Panels are role="region" labelled by18 * the row's disclosure button.19 * Reduced motion: the panel appears instantly.20 */21import { useEffect, useId, useRef, useState, type ReactNode } from "react";22 23export interface ExpandableColumn<T> {24 key: string;25 header: string;26 align?: "left" | "right" | "center";27 width?: number | string;28 render?: (row: T) => ReactNode;29 narrowHide?: boolean;30}31 32export interface ExpandableTableProps<T> {33 data: T[];34 columns: ExpandableColumn<T>[];35 getRowId: (row: T) => string;36 renderDetail: (row: T) => ReactNode;37 caption: string;38 single?: boolean;39 defaultExpanded?: string[];40 expanded?: string[];41 onExpandedChange?: (ids: string[]) => void;42 /** Accessible label for the disclosure button of a row. */43 rowLabel?: (row: T) => string;44 theme?: "light" | "dark";45 className?: string;46}47 48/** Tracks whether the table's own container is narrow, so hidden columns are not rendered at all (keeps colSpan rows correct). */49function useNarrow(max: number) {50 const ref = useRef<HTMLDivElement>(null);51 const [narrow, setNarrow] = useState(false);52 useEffect(() => {53 const el = ref.current;54 if (!el || typeof ResizeObserver === "undefined") return;55 const f = () => setNarrow(el.clientWidth <= max);56 f();57 const ro = new ResizeObserver(f);58 ro.observe(el);59 return () => ro.disconnect();60 }, [max]);61 return [ref, narrow] as const;62}63export function ExpandableTable<T>({64 data,65 columns,66 getRowId,67 renderDetail,68 caption,69 single = false,70 defaultExpanded = [],71 expanded,72 onExpandedChange,73 rowLabel,74 theme,75 className,76}: ExpandableTableProps<T>) {77 const uid = useId();78 const [cardRef, narrow] = useNarrow(720);79 const cols = narrow ? columns.filter((c) => !c.narrowHide) : columns;80 const [inner, setInner] = useState<string[]>(defaultExpanded);81 const open = expanded ?? inner;82 const set = (next: string[]) => {83 if (expanded === undefined) setInner(next);84 onExpandedChange?.(next);85 };86 const toggle = (id: string) => {87 const on = open.includes(id);88 set(on ? open.filter((x) => x !== id) : single ? [id] : [...open, id]);89 };90 91 return (92 <div className={`fd-et${className ? " " + className : ""}`} data-fd-theme={theme}>93 <style>{css}</style>94 <div className="fd-et-card" ref={cardRef}>95 <div className="fd-et-scroll" role="region" aria-label={`${caption}, scrollable`} tabIndex={0}>96 <table className="fd-et-table">97 <caption className="fd-et-sr">{caption}</caption>98 <thead>99 <tr>100 <th scope="col" className="fd-et-th" style={{ width: 52 }}><span className="fd-et-sr">Expand</span></th>101 {cols.map((c) => (102 <th key={c.key} scope="col" className="fd-et-th" data-align={c.align ?? "left"} data-nh={c.narrowHide ? "" : undefined} style={c.width ? { width: c.width } : undefined}>103 {c.header}104 </th>105 ))}106 </tr>107 </thead>108 {data.map((r, i) => {109 const id = getRowId(r);110 const on = open.includes(id);111 const btn = `${uid}-b${i}`;112 const panel = `${uid}-p${i}`;113 return (114 <tbody key={id} className="fd-et-group" data-open={on ? "" : undefined}>115 <tr116 className="fd-et-row"117 onClick={(e) => {118 if ((e.target as HTMLElement).closest("button,a,input,select,textarea,[role=button]")) return;119 toggle(id);120 }}121 >122 <td className="fd-et-td fd-et-lead">123 <button type="button" id={btn} className="fd-et-chev" aria-expanded={on} aria-controls={panel} aria-label={rowLabel ? rowLabel(r) : `Toggle details for row ${i + 1}`} onClick={() => toggle(id)}>124 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M6 3.5L10.5 8 6 12.5" /></svg>125 </button>126 </td>127 {cols.map((c) => (128 <td key={c.key} className="fd-et-td" data-align={c.align ?? "left"} data-nh={c.narrowHide ? "" : undefined}>129 {c.render ? c.render(r) : String((r as Record<string, unknown>)[c.key] ?? "")}130 </td>131 ))}132 </tr>133 <tr className="fd-et-drow">134 <td className="fd-et-dtd" colSpan={cols.length + 1}>135 <div className="fd-et-collapse" id={panel} role="region" aria-labelledby={btn}>136 <div className="fd-et-clip">137 <div className="fd-et-detail">{renderDetail(r)}</div>138 </div>139 </div>140 </td>141 </tr>142 </tbody>143 );144 })}145 </table>146 </div>147 </div>148 </div>149 );150}151 152const css = `153:where(.fd-et,.fd-et *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none;border:0;background:none}154:where(.fd-et,.fd-et *)::before,:where(.fd-et,.fd-et *)::after{box-sizing:border-box}155:where(.fd-et svg){display:block;flex-shrink:0}156:where(.fd-et button){appearance:none;-webkit-appearance:none;outline:0;border-radius:0;cursor:pointer}157:where(.fd-et table){border-collapse:separate;border-spacing:0}158.fd-et{159 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-ease-in-out:cubic-bezier(.65,0,.35,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);--fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;160 --e-bg:#fff;--e-head:#F8F9FB;--e-fg:#0F172A;--e-mut:#64748B;--e-line:rgba(15,23,42,.08);--e-line2:rgba(15,23,42,.16);--e-hover:rgba(15,23,42,.028);--e-open:#F8FAFC;--e-accent:#2563EB;--e-ring:rgba(37,99,235,.5);161 width:100%;font:400 14px/20px ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;letter-spacing:-.006em;color:var(--e-fg);-webkit-font-smoothing:antialiased}162[data-fd-theme="dark"].fd-et,[data-fd-theme="dark"] .fd-et{--e-bg:#0F1218;--e-head:#131720;--e-fg:#E8ECF4;--e-mut:#8A94A8;--e-line:rgba(255,255,255,.07);--e-line2:rgba(255,255,255,.15);--e-hover:rgba(255,255,255,.03);--e-open:#131720;--e-accent:#60A5FA;--e-ring:rgba(96,165,250,.55)}163.fd-et-card{container-type:inline-size;border-radius:16px;background:var(--e-bg);overflow:hidden;box-shadow:0 0 0 1px var(--e-line),0 1px 2px rgba(15,23,42,.04),0 12px 32px -16px rgba(15,23,42,.14)}164.fd-et-scroll{overflow-x:auto}.fd-et-scroll:focus-visible{outline:2px solid var(--e-ring);outline-offset:-2px}165.fd-et-table{width:100%;table-layout:fixed;min-width:560px}166.fd-et-th{height:40px;padding:0 16px;background:var(--e-head);font-size:12px;line-height:16px;font-weight:500;color:var(--e-mut);text-align:left;white-space:nowrap;box-shadow:inset 0 -1px 0 var(--e-line)}167.fd-et-th[data-align="right"],.fd-et-td[data-align="right"]{text-align:right}168.fd-et-th:first-child,.fd-et-lead{padding-left:12px;padding-right:0}169.fd-et-td{height:64px;padding:0 16px;vertical-align:middle;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-shadow:inset 0 -1px 0 var(--e-line);transition:background-color var(--fd-dur-1),box-shadow var(--fd-dur-3) var(--fd-ease-out)}170.fd-et-group:last-child .fd-et-td,.fd-et-group:last-child .fd-et-dtd{box-shadow:none}171.fd-et-row{cursor:pointer}172@media (hover:hover){.fd-et-row:hover .fd-et-td{background:var(--e-hover)}}173.fd-et-group[data-open] .fd-et-td{background:var(--e-open);box-shadow:none}174.fd-et-chev{display:grid;place-items:center;width:32px;height:32px;border-radius:9px;color:var(--e-mut);transition:background-color var(--fd-dur-1),color var(--fd-dur-1),transform var(--fd-dur-3) var(--fd-spring)}175.fd-et-chev svg{transition:transform var(--fd-dur-3) var(--fd-spring)}176.fd-et-chev:hover{background:var(--e-line);color:var(--e-fg)}177.fd-et-chev:active{transform:scale(.9)}178.fd-et-chev[aria-expanded=true]{color:var(--e-fg)}.fd-et-chev[aria-expanded=true] svg{transform:rotate(90deg)}179.fd-et-chev:focus-visible{box-shadow:0 0 0 2px var(--e-bg),0 0 0 4px var(--e-ring)}180.fd-et-dtd{padding:0;background:var(--e-open);box-shadow:inset 0 -1px 0 var(--e-line);border:0;transition:box-shadow var(--fd-dur-3)}181.fd-et-collapse{display:grid;grid-template-rows:0fr;visibility:hidden;transition:grid-template-rows var(--fd-dur-3) var(--fd-ease-out),visibility 0s linear var(--fd-dur-3)}182.fd-et-group[data-open] .fd-et-collapse{grid-template-rows:1fr;visibility:visible;transition-delay:0s}183.fd-et-clip{min-height:0;overflow:hidden}184.fd-et-detail{padding:4px 24px 24px 64px;opacity:0;transform:translateY(-6px);transition:opacity var(--fd-dur-2) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-ease-out)}185.fd-et-group[data-open] .fd-et-detail{opacity:1;transform:none;transition-delay:80ms}186.fd-et-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}187@container (max-width:720px){[data-nh]{display:none}.fd-et-table{min-width:0}.fd-et-th,.fd-et-td{padding:0 12px}.fd-et-th:first-child,.fd-et-lead{padding-left:8px;padding-right:0}.fd-et-detail{padding:4px 16px 20px}}188@media (prefers-reduced-motion:reduce){.fd-et *{transition-duration:1ms!important;transition-delay:0s!important}.fd-et-detail{transform:none}}189@media (prefers-contrast:more){.fd-et-card{box-shadow:0 0 0 1px currentColor}}190`;191 192/* ───────────────────────────── demo ───────────────────────────── */193 194interface Stop { place: string; event: string; when: string; state: "done" | "now" | "next"; note?: string }195interface Shipment {196 id: string;197 ref: string;198 from: string;199 to: string;200 carrier: string;201 mode: "Ocean" | "Air" | "Road" | "Rail";202 eta: string;203 status: "In transit" | "Delayed" | "Delivered" | "Customs hold";204 progress: number;205 weight: string;206 cargo: string;207 stops: Stop[];208}209 210const SHIPMENTS: Shipment[] = [211 {212 id: "s1", ref: "HX-48210", from: "Shenzhen, CN", to: "Rotterdam, NL", carrier: "Maersk Line", mode: "Ocean", eta: "Nov 14", status: "In transit", progress: 62, weight: "18,400 kg", cargo: "2 × 40′ HC · LED lighting",213 stops: [214 { place: "Yantian Port, Shenzhen", event: "Departed", when: "Oct 12 · 06:40", state: "done" },215 { place: "Singapore Strait", event: "Transshipment complete", when: "Oct 19 · 22:15", state: "done" },216 { place: "Suez Canal", event: "Transiting — 14 knots", when: "Oct 29 · now", state: "now", note: "On schedule, 2 days ahead of last vessel" },217 { place: "Port of Rotterdam", event: "Arrival, berth 4", when: "Nov 14 · est.", state: "next" },218 ],219 },220 {221 id: "s2", ref: "HX-48377", from: "Chicago, US", to: "Monterrey, MX", carrier: "Union Pacific", mode: "Rail", eta: "Nov 03", status: "Customs hold", progress: 78, weight: "9,250 kg", cargo: "1 × 53′ · Auto parts",222 stops: [223 { place: "Chicago Intermodal", event: "Loaded", when: "Oct 24 · 14:00", state: "done" },224 { place: "San Antonio Yard", event: "Interchange", when: "Oct 27 · 03:30", state: "done" },225 { place: "Laredo Border", event: "Held — missing pedimento", when: "Oct 29 · 08:12", state: "now", note: "Broker notified. Document due by 17:00 CT." },226 { place: "Monterrey Terminal", event: "Delivery", when: "Nov 03 · est.", state: "next" },227 ],228 },229 {230 id: "s3", ref: "HX-48402", from: "Frankfurt, DE", to: "Singapore, SG", carrier: "Lufthansa Cargo", mode: "Air", eta: "Oct 31", status: "In transit", progress: 41, weight: "640 kg", cargo: "6 pallets · Lab equipment",231 stops: [232 { place: "Frankfurt FRA", event: "Departed", when: "Oct 29 · 01:55", state: "done" },233 { place: "In flight · LH 8160", event: "Over the Caspian Sea", when: "Oct 29 · now", state: "now" },234 { place: "Singapore SIN", event: "Landing", when: "Oct 29 · 22:10 est.", state: "next" },235 { place: "Consignee dock", event: "Delivery", when: "Oct 31 · est.", state: "next" },236 ],237 },238 {239 id: "s4", ref: "HX-47990", from: "Lagos, NG", to: "Accra, GH", carrier: "Trans-West Haulage", mode: "Road", eta: "Oct 30", status: "Delayed", progress: 55, weight: "12,100 kg", cargo: "Pharma · cold chain 2–8°C",240 stops: [241 { place: "Apapa Depot, Lagos", event: "Departed", when: "Oct 27 · 05:00", state: "done" },242 { place: "Seme–Krake Border", event: "Cleared, 9 h queue", when: "Oct 28 · 21:40", state: "done" },243 { place: "Aflao, Ghana", event: "Stopped — road closure", when: "Oct 29 · now", state: "now", note: "Re-routing via Ho. +6 h. Reefer holding 4.1°C." },244 { place: "Tema Warehouse, Accra", event: "Delivery", when: "Oct 30 · est.", state: "next" },245 ],246 },247 {248 id: "s5", ref: "HX-47612", from: "Santos, BR", to: "Hamburg, DE", carrier: "Hapag-Lloyd", mode: "Ocean", eta: "Oct 22", status: "Delivered", progress: 100, weight: "21,000 kg", cargo: "1 × 40′ · Green coffee, 320 bags",249 stops: [250 { place: "Port of Santos", event: "Departed", when: "Sep 28 · 11:20", state: "done" },251 { place: "Algeciras", event: "Bunkering stop", when: "Oct 14 · 04:00", state: "done" },252 { place: "Port of Hamburg", event: "Delivered, signed K. Brandt", when: "Oct 22 · 09:45", state: "done" },253 ],254 },255];256 257const MODE_ICON: Record<Shipment["mode"], string> = {258 Ocean: "M2 12c2 0 2-1.5 4-1.5S8 12 10 12s2-1.5 4-1.5M3.5 9.5L4 5h8l.5 4.5M8 5V2.5",259 Air: "M2 9l12-5-3 9-3-3-2 3v-3z",260 Road: "M1.5 11.5h1M12.5 11.5h2V8l-2-3h-3v6.5M1.5 4.5h8v7M4.5 13a1.2 1.2 0 100-2.4 1.2 1.2 0 000 2.4zM11 13a1.2 1.2 0 100-2.4 1.2 1.2 0 000 2.4z",261 Rail: "M4 2.5h8a1 1 0 011 1V10a2 2 0 01-2 2H5a2 2 0 01-2-2V3.5a1 1 0 011-1zM3 7h10M5 14l1.5-2M11 14l-1.5-2",262};263 264const columns: ExpandableColumn<Shipment>[] = [265 { key: "ref", header: "Shipment", render: (r) => (<span className="fd-etd-refc"><span className="fd-etd-ref">{r.ref}</span><small className="fd-etd-nr">{r.from.split(",")[0]} → {r.to.split(",")[0]}</small></span>) },266 {267 key: "route",268 header: "Route",269 narrowHide: true,270 render: (r) => (271 <span className="fd-etd-route">272 <span>{r.from}</span>273 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M2 7h10M8.5 3.5L12 7l-3.5 3.5" /></svg>274 <span>{r.to}</span>275 </span>276 ),277 },278 {279 key: "mode",280 header: "Carrier",281 width: 200,282 narrowHide: true,283 render: (r) => (284 <span className="fd-etd-car">285 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={MODE_ICON[r.mode]} /></svg>286 {r.carrier}287 </span>288 ),289 },290 { key: "eta", header: "ETA", width: 84, align: "right", narrowHide: true, render: (r) => <span className="fd-etd-eta">{r.eta}</span> },291 { key: "status", header: "Status", width: 148, render: (r) => <span className="fd-etd-pill" data-s={r.status.replace(" ", "-").toLowerCase()}><i />{r.status}</span> },292];293 294function Timeline({ s }: { s: Shipment }) {295 return (296 <ol className="fd-etd-tl" aria-label={`Route for ${s.ref}`}>297 {s.stops.map((st, i) => (298 <li key={i} className="fd-etd-stop" data-state={st.state} data-last={i === s.stops.length - 1 ? "" : undefined} data-held={st.state === "now" && (s.status === "Customs hold" || s.status === "Delayed") ? "" : undefined}>299 <span className="fd-etd-node" aria-hidden="true">300 {st.state === "done" && <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M2 5.3l2.1 2.1L8 3" /></svg>}301 </span>302 <span className="fd-etd-stxt">303 <b>{st.place}</b>304 <span>{st.event}</span>305 {st.note && <em>{st.note}</em>}306 </span>307 <time className="fd-etd-when">{st.when}</time>308 <span className="fd-etd-sr">{st.state === "done" ? "Completed" : st.state === "now" ? "Current" : "Upcoming"}</span>309 </li>310 ))}311 </ol>312 );313}314 315export function Demo() {316 const [theme, setTheme] = useState<"light" | "dark">("light");317 const [open, setOpen] = useState<string[]>(["s2"]);318 const [single, setSingle] = useState(false);319 const all = open.length === SHIPMENTS.length;320 321 return (322 <div className="fd-etd" data-fd-theme={theme}>323 <style>{demoCss}</style>324 <header className="fd-etd-top">325 <span className="fd-etd-brand"><span className="fd-etd-logo" aria-hidden="true">H</span>Haulix <small>Control Tower</small></span>326 <button type="button" className="fd-etd-theme" aria-label={theme === "dark" ? "Switch to light theme" : "Switch to dark theme"} onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>{theme === "dark" ? "Light" : "Dark"}</button>327 </header>328 <main className="fd-etd-main">329 <div className="fd-etd-head">330 <div>331 <h1>Active shipments</h1>332 <p>{SHIPMENTS.filter((s) => s.status !== "Delivered").length} in motion · {SHIPMENTS.filter((s) => s.status === "Delayed" || s.status === "Customs hold").length} need attention</p>333 </div>334 <div className="fd-etd-ctl">335 <label className="fd-etd-chk">336 <input type="checkbox" checked={single} onChange={(e) => { setSingle(e.target.checked); if (e.target.checked) setOpen((o) => o.slice(0, 1)); }} />337 <span>One at a time</span>338 </label>339 <button type="button" className="fd-etd-btn" onClick={() => setOpen(all ? [] : SHIPMENTS.map((s) => s.id))} disabled={single}>{all ? "Collapse all" : "Expand all"}</button>340 </div>341 </div>342 <ExpandableTable<Shipment>343 theme={theme}344 caption="Active shipments"345 data={SHIPMENTS}346 columns={columns}347 getRowId={(r) => r.id}348 single={single}349 expanded={open}350 onExpandedChange={setOpen}351 rowLabel={(r) => `Route details for ${r.ref}, ${r.from} to ${r.to}`}352 renderDetail={(s) => (353 <div className="fd-etd-panel">354 <div className="fd-etd-pl">355 <h2>Route</h2>356 <Timeline s={s} />357 </div>358 <dl className="fd-etd-facts">359 <div><dt>Cargo</dt><dd>{s.cargo}</dd></div>360 <div><dt>Weight</dt><dd>{s.weight}</dd></div>361 <div><dt>Mode</dt><dd>{s.mode} freight · {s.carrier}</dd></div>362 <div><dt>Progress</dt><dd><span className="fd-etd-bar" role="meter" aria-valuemin={0} aria-valuemax={100} aria-valuenow={s.progress} aria-label="Journey progress"><i style={{ width: `${s.progress}%` }} /></span><span className="fd-etd-pct">{s.progress}%</span></dd></div>363 <div className="fd-etd-acts"><button type="button" className="fd-etd-btn">Share tracking</button><button type="button" className="fd-etd-btn fd-etd-solid">Contact carrier</button></div>364 </dl>365 </div>366 )}367 />368 </main>369 </div>370 );371}372 373const demoCss = `374:where(.fd-etd,.fd-etd *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none;border:0;background:none}375:where(.fd-etd,.fd-etd *)::before,:where(.fd-etd,.fd-etd *)::after{box-sizing:border-box}376:where(.fd-etd svg){display:block;flex-shrink:0}377:where(.fd-etd button,.fd-etd input){appearance:none;-webkit-appearance:none;outline:0;border-radius:0}378:where(.fd-etd button){cursor:pointer}379.fd-etd{--fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);380 --d-bg:#F1F4F8;--d-fg:#0F172A;--d-mut:#64748B;--d-line:rgba(15,23,42,.09);--d-card:#fff;--d-accent:#2563EB;--d-ring:rgba(37,99,235,.5);381 min-height:100vh;background:var(--d-bg);color:var(--d-fg);font:400 14px/20px ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;letter-spacing:-.006em;-webkit-font-smoothing:antialiased}382.fd-etd[data-fd-theme="dark"]{--d-bg:#0A0D12;--d-fg:#E8ECF4;--d-mut:#8A94A8;--d-line:rgba(255,255,255,.08);--d-card:#0F1218;--d-accent:#60A5FA;--d-ring:rgba(96,165,250,.55)}383.fd-etd-top{display:flex;align-items:center;justify-content:space-between;height:56px;padding:0 clamp(16px,3vw,32px);background:var(--d-card);box-shadow:inset 0 -1px 0 var(--d-line)}384.fd-etd-brand{display:flex;align-items:center;gap:10px;font-weight:600;font-size:15px;letter-spacing:-.02em}.fd-etd-brand small{font-weight:400;font-size:13px;color:var(--d-mut);margin-left:2px}385.fd-etd-logo{display:grid;place-items:center;width:24px;height:24px;border-radius:7px;background:var(--d-accent);color:var(--d-card);font-size:13px;font-weight:700}386.fd-etd-theme{height:32px;padding:0 12px;border-radius:8px;font-size:13px;font-weight:500;color:var(--d-mut);box-shadow:inset 0 0 0 1px var(--d-line)}.fd-etd-theme:hover{color:var(--d-fg)}387.fd-etd-theme:focus-visible,.fd-etd-btn:focus-visible{outline:2px solid var(--d-ring);outline-offset:2px}388.fd-etd-main{max-width:1080px;margin:0 auto;padding:32px clamp(16px,3vw,32px) 64px}389.fd-etd-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:20px}390.fd-etd-head h1{font-size:24px;line-height:32px;font-weight:600;letter-spacing:-.025em}.fd-etd-head p{margin-top:2px;color:var(--d-mut)}391.fd-etd-ctl{display:flex;align-items:center;gap:16px}392.fd-etd-chk{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;font-weight:500;color:var(--d-mut)}393.fd-etd-chk input{position:relative;width:32px;height:20px;border-radius:9999px;background:var(--d-line);cursor:pointer;transition:background-color 240ms var(--fd-ease-out)}394.fd-etd-chk input::after{content:"";position:absolute;left:2px;top:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.3);transition:transform 420ms var(--fd-spring)}395.fd-etd-chk input:checked{background:var(--d-accent)}.fd-etd-chk input:checked::after{transform:translateX(12px)}396.fd-etd-chk input:focus-visible{outline:2px solid var(--d-ring);outline-offset:2px}397.fd-etd-btn{display:inline-flex;align-items:center;height:32px;padding:0 12px;border-radius:9px;font-size:13px;font-weight:500;line-height:1;background:var(--d-card);box-shadow:0 0 0 1px var(--d-line),0 1px 2px rgba(15,23,42,.05);transition:transform 240ms var(--fd-spring)}398.fd-etd-btn:active:not(:disabled){transform:scale(.96)}.fd-etd-btn:disabled{opacity:.5;cursor:default}399.fd-etd-solid{background:var(--d-fg);color:var(--d-card);box-shadow:none}400.fd-etd-ref{font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.01em}401.fd-etd-refc{display:flex;flex-direction:column;min-width:0}.fd-etd-nr{display:none;font-size:12px;line-height:16px;color:var(--d-mut);overflow:hidden;text-overflow:ellipsis}402@container (max-width:720px){.fd-etd-nr{display:block}}403.fd-etd-route{display:inline-flex;align-items:center;gap:8px}.fd-etd-route svg{color:var(--d-mut)}404.fd-etd-car{display:inline-flex;align-items:center;gap:8px;color:var(--d-mut)}405.fd-etd-eta{font-variant-numeric:tabular-nums;color:var(--d-mut)}406.fd-etd-pill{display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 10px 0 8px;border-radius:9999px;font-size:12px;font-weight:500;line-height:1;--c:#1D4ED8;color:var(--c);background:color-mix(in srgb,var(--c) 10%,transparent)}407.fd-etd-pill i{width:6px;height:6px;border-radius:50%;background:currentColor}408.fd-etd-pill[data-s="delayed"]{--c:#B45309}.fd-etd-pill[data-s="customs-hold"]{--c:#C2271B}.fd-etd-pill[data-s="delivered"]{--c:#15803D}409.fd-etd[data-fd-theme="dark"] .fd-etd-pill{--c:#93C5FD}.fd-etd[data-fd-theme="dark"] .fd-etd-pill[data-s="delayed"]{--c:#FBBF24}.fd-etd[data-fd-theme="dark"] .fd-etd-pill[data-s="customs-hold"]{--c:#FF8A7A}.fd-etd[data-fd-theme="dark"] .fd-etd-pill[data-s="delivered"]{--c:#4ADE80}410.fd-etd-panel{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(0,1fr);gap:32px;padding-top:12px}411.fd-etd-pl h2,.fd-etd-facts dt{font-size:12px;line-height:16px;font-weight:500;color:var(--d-mut)}412.fd-etd-pl h2{margin-bottom:14px}413.fd-etd-tl{display:flex;flex-direction:column}414.fd-etd-stop{position:relative;display:grid;grid-template-columns:20px minmax(0,1fr) auto;column-gap:14px;padding-bottom:20px}415.fd-etd-stop[data-last]{padding-bottom:0}416.fd-etd-stop::before{content:"";position:absolute;left:9px;top:20px;bottom:0;width:2px;border-radius:2px;background:repeating-linear-gradient(to bottom,var(--d-line) 0 4px,transparent 4px 8px)}417.fd-etd-stop[data-state="done"]::before{background:var(--d-accent);opacity:.55}418.fd-etd-stop[data-last]::before{display:none}419.fd-etd-node{display:grid;place-items:center;width:20px;height:20px;border-radius:50%;background:var(--d-card);box-shadow:inset 0 0 0 2px var(--d-line);color:var(--d-card)}420.fd-etd-stop[data-state="done"] .fd-etd-node{background:var(--d-accent);box-shadow:none}421.fd-etd-stop[data-state="now"] .fd-etd-node{box-shadow:inset 0 0 0 2px var(--d-accent),0 0 0 4px color-mix(in srgb,var(--d-accent) 18%,transparent);animation:fd-etd-pulse 2s var(--fd-ease-out) infinite}422.fd-etd-stop[data-held] .fd-etd-node{box-shadow:inset 0 0 0 2px #D97706,0 0 0 4px rgba(217,119,6,.18);animation-name:fd-etd-pulse-w}423.fd-etd-stxt{display:flex;flex-direction:column;min-width:0}424.fd-etd-stxt b{font-weight:500}425.fd-etd-stxt span{font-size:13px;line-height:18px;color:var(--d-mut)}426.fd-etd-stxt em{display:block;margin-top:4px;font-style:normal;font-size:12px;line-height:16px;padding:6px 10px;border-radius:8px;background:var(--d-line);color:var(--d-fg)}427.fd-etd-stop[data-state="next"] .fd-etd-stxt b{color:var(--d-mut)}428.fd-etd-when{font-size:12px;line-height:20px;color:var(--d-mut);font-variant-numeric:tabular-nums;white-space:nowrap}429.fd-etd-facts{display:flex;flex-direction:column;gap:14px;padding:16px 18px;border-radius:14px;background:var(--d-card);box-shadow:0 0 0 1px var(--d-line);align-self:start}430.fd-etd-facts dd{margin-top:2px;font-weight:500}431.fd-etd-facts dd:has(.fd-etd-bar){display:flex;align-items:center;gap:10px}432.fd-etd-bar{flex:1;height:6px;border-radius:9999px;background:var(--d-line);overflow:hidden}.fd-etd-bar i{display:block;height:100%;border-radius:inherit;background:var(--d-accent)}433.fd-etd-pct{font-variant-numeric:tabular-nums;font-size:13px}434.fd-etd-acts{display:flex;gap:8px;padding-top:2px}435.fd-etd-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}436@keyframes fd-etd-pulse{50%{box-shadow:inset 0 0 0 2px var(--d-accent),0 0 0 7px color-mix(in srgb,var(--d-accent) 0%,transparent)}}437@keyframes fd-etd-pulse-w{50%{box-shadow:inset 0 0 0 2px #D97706,0 0 0 7px rgba(217,119,6,0)}}438@media (max-width:720px){.fd-etd-panel{grid-template-columns:1fr;gap:20px}.fd-etd-brand small{display:none}.fd-etd-when{grid-column:2;line-height:16px}.fd-etd-stop{grid-template-columns:20px minmax(0,1fr)}.fd-etd-stxt{grid-column:2}}439@media (prefers-reduced-motion:reduce){.fd-etd *{animation:none!important;transition-duration:1ms!important}}440`;Sortable, selectable, paginated table with sticky header, bulk actions, empty and loading states.
Team members with initial avatars, inline role menu, status pills, filter chips and a hover actions menu.
Editorial league standings with rank-change arrows, zone markers, form dots and rows that FLIP into place.