Trash truck timeline

Public-works dispatcher: each truck glides along its day-long route with spinning wheels; stops, fill levels and statuses update on a 2s tick.

A live, editable Svelte 5 data grid example from the SvGrid gallery (Industry Templates). See the SvGrid documentation for the full API.

About this example

A public-works dispatcher view in the Svelte 5 data grid where every truck row has a wide Route cell laying out the day's stops on a time axis. An SVG truck with spinning wheels glides between stops through a CSS transition on its left percentage, the cell paints a completed zone behind it, each stop shows its scheduled time, and now advances every two seconds so positions reflow as pure derived state. Status pills, fill level and completion KPIs round out the board.

Public-works dispatcher view. Every truck row gets a wide "Route" cell that lays out the day's stops along a time axis. A live truck SVG drives between stops in real time, so the board "moves" even when no one is interacting.

1. **Animated trucks.** The truck icon is positioned by left% with a CSS transition so it glides between stops. A subtle bounce keyframe + spinning wheels suggest motion.

2. **Stop ETAs vs actuals.** Each stop shows its scheduled time. The cell paints a "completed" zone behind the truck so you can see how far through the route it is.

3. **Live tick.** now updates every 2 seconds; cells reflow smoothly because positions are pure derived state.

4. **Status pills, fill level, KPIs.** Real-world ops surface: out-of-service alarms, fill-level capacity, completion %.

Imports, features and API used

Imports: @svgrid/grid

Table features registered: rowSortingFeature, columnFilteringFeature

SvGridApi methods called: api.setColumnWidth()

Frequently asked questions

How does the truck animate?

Its position is a percentage derived from now against the stop times, applied as an inline left style with a CSS transition, so each two-second update glides rather than jumps; a keyframe spins the wheels.

Why derived state instead of an animation loop?

One now value in state drives every truck's position, the completed zone and the KPIs; the browser's transitions smooth the movement without per-frame JavaScript.

How are the fixed columns sized?

onApiReady sets the identity and actions column widths once through api.setColumnWidth so fitColumns gives the remaining width to the Route cell, which keeps the timeline as wide as the viewport allows.

Related documentation

Source code (47-trash-truck-timeline.svelte)

<!-- Documented in: docs/help/recipes.md -->
<script lang="ts">
  /**
   * 47. Trash truck timeline (animated)
   * ------------------------------------
   * Public-works dispatcher view. Every truck row gets a wide
   * "Route" cell that lays out the day's stops along a time axis.
   * A live truck SVG drives between stops in real time, so the
   * board "moves" even when no one is interacting.
   *
   *   1. **Animated trucks.** The truck icon is positioned by `left%`
   *      with a CSS transition so it glides between stops. A subtle
   *      bounce keyframe + spinning wheels suggest motion.
   *
   *   2. **Stop ETAs vs actuals.** Each stop shows its scheduled
   *      time. The cell paints a "completed" zone behind the truck
   *      so you can see how far through the route it is.
   *
   *   3. **Live tick.** `now` updates every 2 seconds; cells reflow
   *      smoothly because positions are pure derived state.
   *
   *   4. **Status pills, fill level, KPIs.** Real-world ops surface:
   *      out-of-service alarms, fill-level capacity, completion %.
   */
  import {
    SvGrid,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    renderSnippet,
    type ColumnDef,
    type SvGridApi,
  } from '@svgrid/grid'

  // ---- Domain ---------------------------------------------------------

  type TruckStatus = 'on-route' | 'at-stop' | 'returning' | 'depot' | 'maintenance'

  type Stop = {
    id: string
    label: string
    /** epoch ms */
    scheduled: number
    type: 'residential' | 'commercial' | 'transfer'
  }

  type Truck = {
    id: string
    plate: string
    driver: string
    zone: string
    capacity: number   // cubic yards
    /** 0–100, increases as the route progresses */
    fillPct: number
    status: TruckStatus
    routeStart: number  // epoch ms
    routeEnd: number    // epoch ms
    stops: Stop[]
  }

  // ---- Day window -----------------------------------------------------

  const DAY_START_HOUR = 6
  const DAY_END_HOUR = 17
  const today = new Date()
  today.setHours(0, 0, 0, 0)
  const DAY_BASE = today.getTime()
  const DAY_START = DAY_BASE + DAY_START_HOUR * 3_600_000
  const DAY_END = DAY_BASE + DAY_END_HOUR * 3_600_000
  const DAY_SPAN_MS = DAY_END - DAY_START

  const HOUR_LABELS: number[] = []
  for (let h = DAY_START_HOUR; h <= DAY_END_HOUR; h += 1) HOUR_LABELS.push(h)

  // ---- Seed -----------------------------------------------------------

  const DRIVERS = [
    'Sasha Park', 'Jamie Chen', 'Casey Singh', 'Drew Olsen',
    'Robin Diaz', 'Morgan Tran', 'Riley Khan', 'Quinn Mehta',
  ]
  const ZONES = ['North Hill', 'Riverside', 'Old Town', 'East Industrial', 'South Park', 'Westgate']
  const PLATE_LETTERS = 'ABCDEFGHJKLMNPRSTUVWXYZ'
  const STOP_NAMES = [
    'Elm St', 'Maple Ave', 'Oak Ln', 'Pine Rd', 'Birch Ct', 'Cedar Way',
    'Park Blvd', 'River Dr', 'Hilltop', 'Sunset Cir', 'Market Sq', 'Front St',
    'Industrial Pk', 'Lakeview', 'Northshore', 'Westwood',
  ]

  let prng = 0x7AB1AB17
  function rnd(): number {
    prng = (prng * 1664525 + 1013904223) >>> 0
    return prng / 0xFFFFFFFF
  }
  function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rnd() * arr.length)]! }
  function randPlate(): string {
    const a = PLATE_LETTERS[Math.floor(rnd() * PLATE_LETTERS.length)]!
    const b = PLATE_LETTERS[Math.floor(rnd() * PLATE_LETTERS.length)]!
    const c = PLATE_LETTERS[Math.floor(rnd() * PLATE_LETTERS.length)]!
    const n = Math.floor(rnd() * 9000) + 1000
    return `${a}${b}${c}-${n}`
  }

  function buildTruck(idx: number): Truck {
    const id = `T-${(idx + 1).toString().padStart(2, '0')}`
    const startOffsetMin = idx * 7 + Math.floor(rnd() * 15)
    const durHours = 6 + Math.floor(rnd() * 4)
    const routeStart = DAY_START + startOffsetMin * 60_000
    const routeEnd = Math.min(DAY_END, routeStart + durHours * 3_600_000)
    const stopCount = 6 + Math.floor(rnd() * 6)
    const stops: Stop[] = []
    const span = routeEnd - routeStart
    for (let s = 0; s < stopCount; s += 1) {
      const t = routeStart + Math.round(((s + 0.5) / stopCount) * span)
      stops.push({
        id: `${id}-S${s + 1}`,
        label: pick(STOP_NAMES),
        scheduled: t,
        type: rnd() < 0.7 ? 'residential' : rnd() < 0.85 ? 'commercial' : 'transfer',
      })
    }
    // Status is mostly on-route at this hour; sprinkle a few that aren't.
    const roll = rnd()
    const status: TruckStatus =
      idx === 6 ? 'maintenance' :
      idx === 7 ? 'depot' :
      roll < 0.55 ? 'on-route' : roll < 0.78 ? 'at-stop' : 'returning'
    return {
      id,
      plate: randPlate(),
      driver: DRIVERS[idx]!,
      zone: ZONES[idx % ZONES.length]!,
      capacity: 18 + Math.floor(rnd() * 5) * 2,
      fillPct: status === 'depot' || status === 'maintenance' ? 0 : 25 + Math.floor(rnd() * 60),
      status,
      routeStart,
      routeEnd,
      stops,
    }
  }

  function buildFleet(): Truck[] {
    const out: Truck[] = []
    for (let i = 0; i < 8; i += 1) out[i] = buildTruck(i)
    return out
  }

  // ---- State ----------------------------------------------------------

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
  let fleet = $state<Truck[]>(buildFleet())

  // Live "now" - accelerated so the trucks visibly drive across the
  // cells. We map one real second to a handful of demo minutes so a
  // four-second visit shows meaningful movement, while the wall-clock
  // axis still reads naturally. Resets to mid-morning on mount.
  const DEMO_SPEED = 90  // 1 real second = 90 demo seconds (1.5 demo minutes)
  let now = $state(DAY_START + 90 * 60_000)  // 07:30 demo time
  $effect(() => {
    // 200 ms cadence keeps motion buttery without flooding the renderer.
    let cancelled = false
    const id = setInterval(() => {
      if (cancelled) return
      const next = now + 200 * DEMO_SPEED
      // Loop the day so the animation never stalls.
      now = next > DAY_END - 30 * 60_000 ? DAY_START + 30 * 60_000 : next
    }, 200)
    return () => { cancelled = true; clearInterval(id) }
  })

  // Fill levels drift up over time so the demo shows realistic load
  // accumulation. Trucks at depot/maintenance stay at zero.
  $effect(() => {
    const id = setInterval(() => {
      fleet = fleet.map((t) => {
        if (t.status === 'depot' || t.status === 'maintenance') return t
        const drift = Math.random() < 0.5 ? 1 : 2
        return { ...t, fillPct: Math.min(100, t.fillPct + drift) }
      })
    }, 4500)
    return () => clearInterval(id)
  })

  // ---- Layout: keep the axis row exactly as wide as the Route cell
  // so the red "now" line aligns vertically across header + every row.

  /** Fixed columns left of the Route timeline. The axis reserves this much. */
  const LEFT_W = { id: 230, zone: 130, status: 130, fillPct: 170 }
  const LEFT_COLS_WIDTH = LEFT_W.id + LEFT_W.zone + LEFT_W.status + LEFT_W.fillPct
  /** Trailing Actions column (right of the timeline). */
  const RIGHT_COLS_WIDTH = 170

  // The Route column GROWS to fill the width between the fixed left columns and
  // the trailing Actions column. Instead of measuring the wrapper (fragile -
  // misses the grid's real client width + scrollbars), we let the grid fill
  // itself: `fitColumns` stretches the unpinned columns, and we PIN every
  // fixed column (left + the Actions column) so ONLY the Route column absorbs
  // the slack. The header axis flex-fills the same remaining space.
  let pinnedFixed = false
  function pinFixedColumns(api: SvGridApi<typeof features, Truck>) {
    if (pinnedFixed) return
    pinnedFixed = true
    for (const [id, w] of Object.entries(LEFT_W)) api.setColumnWidth(id, w)
    api.setColumnWidth('plate', RIGHT_COLS_WIDTH) // trailing Actions column
  }

  // ---- Geometry helpers ----------------------------------------------

  function pctOf(ts: number): number { return ((ts - DAY_START) / DAY_SPAN_MS) * 100 }
  function clampPct(p: number): number { return Math.max(0, Math.min(100, p)) }

  /**
   * Where the truck icon sits right now. For an active truck this is
   * the linear interpolation between `routeStart` and `routeEnd`; for
   * idle trucks the icon stays parked at the depot (the right edge).
   */
  function truckPct(t: Truck): number {
    if (t.status === 'depot' || t.status === 'maintenance') return clampPct(pctOf(t.routeStart))
    const span = t.routeEnd - t.routeStart
    if (span <= 0) return clampPct(pctOf(t.routeStart))
    const frac = (now - t.routeStart) / span
    const start = pctOf(t.routeStart)
    const end = pctOf(t.routeEnd)
    return clampPct(start + Math.max(0, Math.min(1, frac)) * (end - start))
  }

  /** How far the truck has progressed through its stops (0–100). */
  function progressPct(t: Truck): number {
    if (t.status === 'depot' || t.status === 'maintenance') return 0
    if (now <= t.routeStart) return 0
    if (now >= t.routeEnd) return 100
    return Math.round(((now - t.routeStart) / (t.routeEnd - t.routeStart)) * 100)
  }

  // ---- KPIs -----------------------------------------------------------

  const kpis = $derived.by(() => {
    const active = fleet.filter((t) => t.status === 'on-route' || t.status === 'at-stop').length
    const returning = fleet.filter((t) => t.status === 'returning').length
    const oos = fleet.filter((t) => t.status === 'maintenance').length
    const stopsDone = fleet.reduce((s, t) => s + t.stops.filter((sp) => sp.scheduled <= now).length, 0)
    const stopsTotal = fleet.reduce((s, t) => s + t.stops.length, 0)
    const avgFill = fleet.length
      ? Math.round(fleet.reduce((s, t) => s + t.fillPct, 0) / fleet.length)
      : 0
    const fullSoon = fleet.filter((t) => t.fillPct >= 80).length
    return { active, returning, oos, stopsDone, stopsTotal, avgFill, fullSoon }
  })

  // ---- Actions --------------------------------------------------------

  function sendToDepot(id: string): void {
    fleet = fleet.map((t) => (t.id === id ? { ...t, status: 'returning' as TruckStatus } : t))
  }
  function flagMaintenance(id: string): void {
    fleet = fleet.map((t) => (t.id === id ? { ...t, status: 'maintenance' as TruckStatus, fillPct: 0 } : t))
  }
  function dispatchAll(): void {
    fleet = fleet.map((t) => (t.status === 'depot' ? { ...t, status: 'on-route' as TruckStatus, routeStart: now } : t))
  }

  // ---- Formatters -----------------------------------------------------

  function fmtTime(ts: number): string {
    return new Date(ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
  }
  function statusLabel(s: TruckStatus): string {
    switch (s) {
      case 'on-route':    return 'On route'
      case 'at-stop':     return 'At stop'
      case 'returning':   return 'Returning'
      case 'depot':       return 'At depot'
      case 'maintenance': return 'Maintenance'
    }
  }
</script>

<!-- ───────────────────── CELL SNIPPETS ───────────────────── -->

{#snippet TruckCell(props: { row: Truck })}
  <span class="tk-truck">
    <span class={`tk-truck-icon tk-status-${props.row.status}`} aria-hidden="true">
      <!-- Inline truck SVG. Wheels spin via CSS keyframes when active. -->
      <svg viewBox="0 0 60 32" width="42" height="22">
        <!-- cab -->
        <rect x="34" y="6" width="18" height="14" rx="2" />
        <rect x="36" y="9" width="6" height="6" class="tk-window" />
        <!-- body -->
        <rect x="4" y="3" width="34" height="20" rx="2" />
        <!-- lift arm -->
        <path d="M2 6 L8 6 L8 11 L4 11 Z" />
        <!-- wheels -->
        <circle cx="14" cy="26" r="4" class="tk-wheel" />
        <circle cx="44" cy="26" r="4" class="tk-wheel" />
        <circle cx="14" cy="26" r="1.5" class="tk-hub" />
        <circle cx="44" cy="26" r="1.5" class="tk-hub" />
      </svg>
    </span>
    <span class="tk-truck-text">
      <span class="tk-truck-id">{props.row.id} · {props.row.plate}</span>
      <span class="tk-truck-driver">{props.row.driver}</span>
    </span>
  </span>
{/snippet}

{#snippet StatusCell(props: { row: Truck })}
  <span class={`tk-status-pill tk-status-${props.row.status}`}>{statusLabel(props.row.status)}</span>
{/snippet}

{#snippet FillCell(props: { row: Truck })}
  {@const high = props.row.fillPct >= 80}
  <span class="tk-fill">
    <span class="tk-fill-bar">
      <span class={`tk-fill-level ${high ? 'tk-fill-high' : ''}`} style={`width: ${props.row.fillPct}%`}></span>
    </span>
    <span class="tk-fill-text tabular-nums">{props.row.fillPct}% <span class="tk-muted">/ {props.row.capacity}yd³</span></span>
  </span>
{/snippet}

{#snippet RouteCell(props: { row: Truck })}
  {@const tPct = truckPct(props.row)}
  {@const progress = progressPct(props.row)}
  {@const idle = props.row.status === 'depot' || props.row.status === 'maintenance'}
  <span class="tk-route">
    <!-- Hour grid behind the route -->
    {#each HOUR_LABELS.slice(0, -1) as h (h)}
      <span class="tk-grid-tick" style={`left: ${((h - DAY_START_HOUR) / (DAY_END_HOUR - DAY_START_HOUR)) * 100}%`}></span>
    {/each}

    <!-- The route corridor -->
    {#if !idle}
      <span class="tk-route-line" style={`left: ${pctOf(props.row.routeStart)}%; width: ${pctOf(props.row.routeEnd) - pctOf(props.row.routeStart)}%`}></span>
      <span class="tk-route-progress" style={`left: ${pctOf(props.row.routeStart)}%; width: ${(progress / 100) * (pctOf(props.row.routeEnd) - pctOf(props.row.routeStart))}%`}></span>
    {/if}

    <!-- Stops -->
    {#each props.row.stops as s (s.id)}
      {@const done = s.scheduled <= now && !idle}
      <span
        class={`tk-stop tk-stop-${s.type} ${done ? 'tk-stop-done' : ''}`}
        style={`left: ${pctOf(s.scheduled)}%`}
        title={`${s.label} · ${fmtTime(s.scheduled)} · ${s.type}`}
      >
        <span class="tk-stop-dot"></span>
        <span class="tk-stop-label">{s.label}</span>
      </span>
    {/each}

    <!-- Now line -->
    <span class="tk-now" style={`left: ${pctOf(now)}%`}></span>

    <!-- The truck itself, gliding between stops -->
    <span
      class={`tk-mover tk-mover-${props.row.status}`}
      style={`left: ${tPct}%`}
      aria-hidden="true"
    >
      <svg viewBox="0 0 60 32" width="44" height="24">
        <rect x="34" y="6" width="18" height="14" rx="2" />
        <rect x="36" y="9" width="6" height="6" class="tk-window" />
        <rect x="4" y="3" width="34" height="20" rx="2" />
        <path d="M2 6 L8 6 L8 11 L4 11 Z" />
        <circle cx="14" cy="26" r="4" class="tk-wheel" />
        <circle cx="44" cy="26" r="4" class="tk-wheel" />
        <circle cx="14" cy="26" r="1.5" class="tk-hub" />
        <circle cx="44" cy="26" r="1.5" class="tk-hub" />
      </svg>
    </span>
  </span>
{/snippet}

{#snippet ActionsCell(props: { row: Truck })}
  <span class="tk-actions">
    <button type="button" class="tk-btn" onclick={() => sendToDepot(props.row.id)} disabled={props.row.status === 'depot' || props.row.status === 'maintenance'}>Recall</button>
    <button type="button" class="tk-btn tk-btn-warn" onclick={() => flagMaintenance(props.row.id)} disabled={props.row.status === 'maintenance'}>Service</button>
  </span>
{/snippet}

<!-- ───────────────────── LAYOUT ───────────────────── -->

<section class="tk-shell flex flex-col flex-1 min-h-0 gap-3" data-mobile-pan>
  <!-- KPI strip -->
  <div class="tk-kpi-strip">
    <div class="tk-kpi">
      <div class="tk-kpi-label">Active trucks</div>
      <div class="tk-kpi-value tabular-nums">{kpis.active}<span class="tk-of">/{fleet.length}</span></div>
      <div class="tk-kpi-foot">{kpis.returning} returning · {kpis.oos} in service</div>
    </div>
    <div class="tk-kpi">
      <div class="tk-kpi-label">Stops complete</div>
      <div class="tk-kpi-value tabular-nums">{kpis.stopsDone}<span class="tk-of">/{kpis.stopsTotal}</span></div>
      <div class="tk-kpi-foot">{kpis.stopsTotal ? Math.round((kpis.stopsDone / kpis.stopsTotal) * 100) : 0}% of day's stops</div>
    </div>
    <div class="tk-kpi">
      <div class="tk-kpi-label">Avg fill</div>
      <div class={`tk-kpi-value tabular-nums ${kpis.avgFill > 75 ? 'tk-warn' : ''}`}>{kpis.avgFill}%</div>
      <div class="tk-kpi-foot">{kpis.fullSoon} truck{kpis.fullSoon === 1 ? '' : 's'} ≥ 80%</div>
    </div>
    <div class="tk-kpi">
      <div class="tk-kpi-label">Now</div>
      <div class="tk-kpi-value tabular-nums">{fmtTime(now)}</div>
      <div class="tk-kpi-foot">{today.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })}</div>
    </div>
    <div class="tk-kpi tk-kpi-action">
      <button type="button" class="tk-btn tk-btn-primary" onclick={dispatchAll}>Dispatch depot trucks</button>
      <div class="tk-kpi-foot">moves all "At depot" → On route</div>
    </div>
  </div>

  <div class="flex-1 min-h-0 flex flex-col">
    <!-- Hour axis. Sits in the same wrapper as the grid so its width
         tracks the grid exactly - that's what keeps the red "now"
         line vertically aligned between this row and every cell. -->
    <div class="tk-axis-wrap">
      <div class="tk-axis-spacer" style={`flex: 0 0 ${LEFT_COLS_WIDTH}px`}></div>
      <div class="tk-axis">
        {#each HOUR_LABELS as h, i (h)}
          <div class="tk-axis-tick" style={`left: ${(i / (HOUR_LABELS.length - 1)) * 100}%`}>
            <span>{h.toString().padStart(2, '0')}:00</span>
          </div>
        {/each}
        <div class="tk-axis-now" style={`left: ${pctOf(now)}%`}><span>now</span></div>
      </div>
      <div class="tk-axis-end" style={`flex: 0 0 ${RIGHT_COLS_WIDTH}px`}></div>
    </div>

    <!-- Grid -->
    <div class="flex-1 min-h-0">
      <SvGrid responsive={true}
      columnResize
        data={fleet}
        columns={[
          { field: 'id', header: 'Truck', width: LEFT_W.id, editable: false,
            cell: (ctx) => renderSnippet(TruckCell, { row: ctx.row.original }) },
          { field: 'zone', header: 'Zone', width: LEFT_W.zone, editable: false },
          { field: 'status', header: 'Status', width: LEFT_W.status, editable: false,
            cell: (ctx) => renderSnippet(StatusCell, { row: ctx.row.original }) },
          { field: 'fillPct', header: 'Fill', width: LEFT_W.fillPct, editable: false,
            cell: (ctx) => renderSnippet(FillCell, { row: ctx.row.original }) },
          // Base width is a seed - fitColumns stretches this one column to fill.
          { field: 'routeStart', header: 'Route', width: 400, editable: false,
            cell: (ctx) => renderSnippet(RouteCell, { row: ctx.row.original }) },
          { field: 'plate', header: '', width: RIGHT_COLS_WIDTH, editable: false,
            cell: (ctx) => renderSnippet(ActionsCell, { row: ctx.row.original }) },
        ] satisfies ColumnDef<typeof features, Truck>[]}
        features={features}
        filterMode="menu"
        selectionMode="row"
        showRowSelection={false}
        enableInlineEditing={false}
        enableCellSelection={false}
        rowHeight={58}
        containerHeight="100%"
        fitColumns={true}
        onApiReady={pinFixedColumns}
      />
    </div>
  </div>
</section>

<style>
  /* KPI strip */
  .tk-kpi-strip {
    display: grid;
    grid-template-columns: repeat(5, minmax(0, 1fr));
    gap: 10px;
    flex-shrink: 0;
  }
  .tk-kpi {
    border: 1px solid var(--sg-border, #e2e8f0);
    background: var(--sg-bg, #ffffff);
    border-radius: 10px;
    padding: 12px 14px;
  }
  .tk-kpi-label {
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--sg-muted, #64748b);
    margin-bottom: 6px;
  }
  .tk-kpi-value { font-size: 22px; font-weight: 700; line-height: 1.1; }
  .tk-of { font-size: 14px; font-weight: 500; color: var(--sg-muted, #64748b); margin-left: 3px; }
  .tk-kpi-foot { margin-top: 6px; font-size: 11px; color: var(--sg-muted, #64748b); }
  .tk-warn { color: #d97706; }
  :global([data-theme='dark']) .tk-warn { color: #fbbf24; }
  .tk-kpi-action { display: flex; flex-direction: column; justify-content: center; align-items: stretch; gap: 6px; }

  /* Hour axis. NO border-left and NO spacer border-right - the grid's
   * cells have no equivalent edges, so any pixels we add here would
   * push the axis "now" line out of sync with the in-cell line. */
  .tk-axis-wrap {
    display: flex;
    flex-shrink: 0;
    border-top: 1px solid var(--sg-border, #e2e8f0);
    border-bottom: 1px solid var(--sg-border, #e2e8f0);
    background: var(--sg-header-bg, #f1f5f9);
    overflow: hidden;
    margin-bottom: 6px;
    height: 30px;
  }
  .tk-axis-spacer { flex-shrink: 0; }
  .tk-axis-end    { flex-shrink: 0; }
  .tk-axis {
    position: relative;
    /* Grow to fill the space between the left spacer and the trailing
       Actions spacer, matching the Route column (which also fills it). */
    flex: 1 1 0;
    min-width: 0;
    height: 28px;
  }
  .tk-axis-tick {
    position: absolute;
    top: 0; bottom: 0;
    border-left: 1px solid var(--sg-border, #e2e8f0);
    padding-left: 4px;
    font-size: 10.5px;
    color: var(--sg-muted, #64748b);
    display: flex;
    align-items: center;
  }
  .tk-axis-now {
    position: absolute;
    top: 2px; bottom: 2px;
    border-left: 2px solid #dc2626;
    padding-left: 4px;
    display: flex;
    align-items: center;
    font-size: 10px;
    font-weight: 700;
    color: #dc2626;
    text-transform: uppercase;
    letter-spacing: 0.06em;
  }

  /* Truck cell (static icon next to the driver name) */
  :global(.tk-truck) { display: inline-flex; align-items: center; gap: 8px; }
  :global(.tk-truck-icon) {
    width: 44px;
    height: 30px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border-radius: 6px;
    background: var(--sg-bg-subtle, var(--sg-header-bg, #f1f5f9));
    fill: var(--sg-fg, #1e293b);
  }
  :global(.tk-truck-text) { display: inline-flex; flex-direction: column; line-height: 1.2; }
  :global(.tk-truck-id) { font-weight: 600; font-size: 12.5px; font-variant-numeric: tabular-nums; }
  :global(.tk-truck-driver) {
    font-size: 11px;
    color: var(--sg-muted, #64748b);
  }
  :global(.tk-window) { fill: #93c5fd; }
  :global(.tk-wheel) { fill: #0f172a; }
  :global(.tk-hub)   { fill: #cbd5e1; }
  :global([data-theme='dark'] .tk-wheel) { fill: #0f172a; }
  :global([data-theme='dark'] .tk-hub) { fill: #64748b; }

  /* Status pills */
  :global(.tk-status-pill) {
    display: inline-block;
    padding: 2px 9px;
    border-radius: 999px;
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.02em;
  }
  :global(.tk-status-on-route)    { background: #dcfce7; color: #166534; }
  :global(.tk-status-at-stop)     { background: #dbeafe; color: #1d4ed8; }
  :global(.tk-status-returning)   { background: #fef3c7; color: #92400e; }
  :global(.tk-status-depot)       { background: #e2e8f0; color: #475569; }
  :global(.tk-status-maintenance) { background: #fee2e2; color: #b91c1c; }
  :global([data-theme='dark'] .tk-status-on-route)    { background: rgba(34,197,94,.18); color: #4ade80; }
  :global([data-theme='dark'] .tk-status-at-stop)     { background: rgba(59,130,246,.18); color: #93c5fd; }
  :global([data-theme='dark'] .tk-status-returning)   { background: rgba(245,158,11,.18); color: #fbbf24; }
  :global([data-theme='dark'] .tk-status-depot)       { background: rgba(148,163,184,.18); color: #cbd5e1; }
  :global([data-theme='dark'] .tk-status-maintenance) { background: rgba(239,68,68,.18); color: #f87171; }

  /* Fill cell */
  :global(.tk-fill) { display: inline-flex; flex-direction: column; gap: 4px; width: 100%; }
  :global(.tk-fill-bar) {
    display: block;
    height: 6px;
    background: var(--sg-input-bg, #e2e8f0);
    border-radius: 999px;
    overflow: hidden;
  }
  :global(.tk-fill-level) {
    display: block;
    height: 100%;
    background: linear-gradient(90deg, #16a34a, #4ade80);
    border-radius: 999px;
    transition: width 600ms ease;
  }
  :global(.tk-fill-high) { background: linear-gradient(90deg, #d97706, #f59e0b); }
  :global(.tk-fill-text) { font-size: 11px; line-height: 1; }
  :global(.tk-muted) { color: var(--sg-muted, #64748b); }

  /* Route cell */
  :global(.tk-route) {
    position: relative;
    display: block;
    height: 50px;
    /* Cancel the grid cell's 7px left padding so the timeline owns
     * every pixel of the Route column - that keeps the red "now"
     * line lined up with the header axis above. */
    width: calc(100% + 7px);
    margin-left: -7px;
  }
  :global(.tk-grid-tick) {
    position: absolute;
    top: 0; bottom: 0;
    width: 0;
    border-left: 1px dashed rgba(148, 163, 184, 0.25);
  }
  :global(.tk-route-line) {
    position: absolute;
    top: 24px; height: 4px;
    border-radius: 4px;
    background: rgba(148, 163, 184, 0.4);
  }
  :global(.tk-route-progress) {
    position: absolute;
    top: 24px; height: 4px;
    border-radius: 4px;
    background: var(--sg-accent, #2563eb);
    transition: width 220ms linear;
  }
  :global(.tk-stop) {
    position: absolute;
    top: 6px; bottom: 6px;
    transform: translateX(-50%);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: flex-start;
    line-height: 1;
    pointer-events: auto;
  }
  :global(.tk-stop-dot) {
    width: 9px;
    height: 9px;
    border-radius: 50%;
    margin-top: 19px;
    background: #94a3b8;
    border: 2px solid var(--sg-bg, #ffffff);
  }
  :global(.tk-stop-residential .tk-stop-dot) { background: #2563eb; }
  :global(.tk-stop-commercial  .tk-stop-dot) { background: #7c3aed; }
  :global(.tk-stop-transfer    .tk-stop-dot) { background: #f59e0b; }
  :global(.tk-stop-done .tk-stop-dot) {
    background: #16a34a !important;
    box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.22);
  }
  :global(.tk-stop-label) {
    font-size: 9.5px;
    color: var(--sg-muted, #64748b);
    margin-top: 3px;
    white-space: nowrap;
    transform: rotate(-20deg);
    transform-origin: center left;
    opacity: 0.85;
  }

  :global(.tk-now) {
    position: absolute;
    top: 0; bottom: 0;
    border-left: 2px solid #dc2626;
    pointer-events: none;
  }

  /* The animated mover */
  :global(.tk-mover) {
    position: absolute;
    top: 0;
    transform: translateX(-50%);
    display: inline-flex;
    align-items: center;
    fill: #0f172a;
    transition: left 220ms linear;
    animation: tk-bounce 1.6s ease-in-out infinite;
    filter: drop-shadow(0 2px 3px rgba(15, 23, 42, 0.25));
  }
  :global(.tk-mover svg) { display: block; }
  :global(.tk-mover .tk-wheel) {
    transform-origin: center;
    transform-box: fill-box;
    animation: tk-spin 0.6s linear infinite;
  }

  :global(.tk-mover-on-route)    { fill: #16a34a; }
  :global(.tk-mover-at-stop)     { fill: #2563eb; animation-duration: 0.9s; }
  :global(.tk-mover-at-stop .tk-wheel) { animation-play-state: paused; }
  :global(.tk-mover-returning)   { fill: #d97706; }
  :global(.tk-mover-depot) {
    fill: #64748b;
    animation: none;
    opacity: 0.7;
  }
  :global(.tk-mover-depot .tk-wheel) { animation: none; }
  :global(.tk-mover-maintenance) {
    fill: #b91c1c;
    animation: none;
    opacity: 0.55;
  }
  :global(.tk-mover-maintenance .tk-wheel) { animation: none; }

  :global([data-theme='dark'] .tk-mover-on-route)  { fill: #4ade80; }
  :global([data-theme='dark'] .tk-mover-at-stop)   { fill: #93c5fd; }
  :global([data-theme='dark'] .tk-mover-returning) { fill: #fbbf24; }
  :global([data-theme='dark'] .tk-mover-depot)     { fill: #cbd5e1; }
  :global([data-theme='dark'] .tk-mover-maintenance) { fill: #f87171; }

  @keyframes tk-bounce {
    0%, 100% { transform: translate(-50%, 0); }
    50%      { transform: translate(-50%, -2px); }
  }
  @keyframes tk-spin {
    from { transform: rotate(0deg); }
    to   { transform: rotate(360deg); }
  }

  /* Actions cell */
  :global(.tk-actions) { display: inline-flex; gap: 6px; }
  :global(.tk-btn) {
    border: 1px solid var(--sg-border, #cbd5e1);
    background: var(--sg-bg, #ffffff);
    color: var(--sg-fg, #1e293b);
    border-radius: 5px;
    padding: 3px 9px;
    font-size: 11px;
    cursor: pointer;
  }
  :global(.tk-btn:hover:not(:disabled)) { background: var(--sg-header-bg, #f1f5f9); }
  :global(.tk-btn:disabled) { opacity: 0.4; cursor: not-allowed; }
  :global(.tk-btn-warn) { color: #b91c1c; border-color: #fecaca; }
  :global([data-theme='dark'] .tk-btn-warn) { color: #f87171; border-color: rgba(239,68,68,0.3); }
  :global(.tk-btn-primary) {
    border-color: transparent;
    background: var(--sg-accent, #2563eb);
    color: var(--sg-on-accent, #fff);
  }

  /* Mobile: `.tk-axis-wrap` hides its overflow, so the ~830px route timeline
     was silently cut off at ~366px on a phone. Floor the whole shell rather
     than scrolling the axis alone - the axis and the truck rows must pan
     together to stay aligned. The stage then becomes a contained scroller
     (see .demo-stage.is-wide in examples/src/mobile.css). */
  @media (max-width: 767px) {
    .tk-shell {
      min-width: 860px;
    }
  }
</style>

View this example on GitHub

More Industry Templates examples

  • HR team directory - Employee directory with avatars, status badges, group by team / location / status.
  • Finances - ledger - Account ledger with running balance, currency formatting, category chips, paging.
  • Manufacturing operations - Plant-floor view: KPI cards + active-runs grid with progress bars, status pills, live 5-second tick.
  • Permissions, audit & history - Role-based cell editing, PII masking, full audit log with one-click revert, per-cell history popover.
  • Print + boardroom export - Quarterly P&L print pack: cover page, repeat-on-page headers, page-size + orientation, CSV / HTML download, browser-native PDF.