Skip to content
KONIGI Patterns

Dashboards / Charts and data displays / Bed map and track board

‹ 2 of 22 ›

Bed map and track board

Staff need to see bed availability and what each patient is waiting for in one place.

Updated September 10, 2026

Problem

Staff need to see which beds are available and what each occupied bed is waiting for without checking separate records.

Solution

Show a stable row or cell for every bed, including empty ones. Arrange beds by physical location or the order staff use to coordinate work.

Display the current state with text or icons as well as color. Make the next required step and time spent waiting easy to find. Link to details for people who need them, while keeping essential information visible without interaction.

Choose labels and identifying details for the people who can see the display. Keep the board readable from its actual viewing distance.

Use when

Several roles coordinate occupants moving through a fixed set of physical spaces.

Don’t use when

The resource is elastic or virtual. If capacity can be added on demand there is no fixed grid to mirror, and a queue or a ranked list serves better.

Trade-offs

Shared displays can expose private information. Abbreviations save room but may be misunderstood. Too many status categories slow scanning, and information hidden behind a click will be missed by people reading from a distance.

Checklist

  • Is every slot always shown, including empty ones?
  • Does each row say what the occupant is waiting on, and for how long?
  • How many distinct statuses exist, and can staff name them all?
  • Is status readable without colour alone, for both accessibility and a bad screen?
  • Does the layout mirror the physical space or the priority order, and is that consistent?
  • What is visible to people who should not see it, from where they actually stand?
  • Is anything important behind hover or a click on a display nobody touches?
  • How does the board behave when the source system is unreachable?
  • Is elapsed time shown, and does it make long waits obvious?
  • Who owns the status vocabulary, and what stops it growing?

Compare

Epic and Cerner ship the dominant hospital versions as configurable track boards inside the record system, so the board inherits the EHR’s data model and its constraints, and customisation happens through configuration rather than design. TeleTracking and similar operations platforms approach it from capacity management rather than clinical care, so the same grid is oriented toward throughput and bed turnover. Airport and rail operations boards solve a structurally identical problem for gates and platforms, and are worth studying because their audience is partly the public, which forces a clarity most clinical boards do not attempt. Netdata’s node view is the infrastructure cousin, and the contrast is instructive: it optimises for one operator finding an anomaly, where a track board optimises for many people sharing a model.

Host map uses a similar overview for infrastructure. Data table supports a row-based board. Wallboard mode addresses distant viewing.

Semantic status color defines consistent states. Status history shows how those states changed over time.

Bed map and track board anatomy Every bed in the department gets a row whether or not anyone is in it. Two rows are empty, which is availability and often the most useful cell on the board. A "waiting for" column carries what each patient is blocked on and how long, which is the column that changes what a passer-by does. Every bed present, occupied or not Bed Patient Triage Waiting for Elapsed A1 1 CT result 2h 14m A2 2 bloods 41m A3 empty · ready — A4 3 bed on ward 7 5h 02m A5 2 senior review 28m A6 empty · needs clean 12m 1 2 3 1 EMPTY IS INFORMATION An unoccupied bed isn't an absent row, it's availability, and it's often the most valuable cell on the board. That's the opposite of most dashboards, where nothing means nothing. 2 WAITING FOR Occupancy is easy. The column that changes behaviour is what each patient is blocked on and how long they've been blocked, because that is what a passer-by can act on. 3 A SHARED MODEL, NOT A FINDING A host map exists so one person can find an anomaly. A track board exists so a dozen people with different jobs read different columns off the same board.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

A ward laid out as it is walked. Every cell is a person, which is why the colours have to be sober and why an unknown state must never look like a comfortable one.

shadcn
npx shadcn@latest add table
Tokens
--card--card-foreground--muted-foreground--border--status-critical--status-warn--state-stale
BedPatientTriageWaiting forElapsed
A1R. M.1CT result2h 14m
A2J. O.2bloods41m
A3empty · ready—
A4S. K.3bed on ward 75h 02m
A5D. A.2senior review28m
A6empty · needs clean12m

TrackBoard.tsxOne row per bed, empty ones included. Elapsed is computed from a timestamp, and a wait past the threshold turns the row critical whatever the triage says.

import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import type { Status } from "../semantic-status-color/status";

/** Five triage categories, and no sixth. */
export type Triage = 1 | 2 | 3 | 4 | 5;

/**
 * Every bed is one of these. An empty bed is a row, never a gap: it is
 * availability, and a board that drops it has hidden the most useful cell.
 */
export type Bed =
  | { id: string; state: "occupied"; patient: string; triage: Triage; waitingFor: string; since: Date }
  | { id: string; state: "empty"; ready: true }
  | { id: string; state: "empty"; ready: false; since: Date };

type Props = {
  beds: Bed[];
  /** A wait this long is a critical row whatever the triage says. */
  longWaitMs: number;
  /** The clock to measure waits against. Pass one to render on a server. */
  now?: Date;
};

/** Triage 1 is critical, 2 and 3 want a person, 4 and 5 are waiting normally. */
const TRIAGE_STATUS: Record<Triage, Status> = { 1: "critical", 2: "warn", 3: "warn", 4: "nominal", 5: "nominal" };

const TEXT: Record<Status, string> = {
  critical: "text-status-critical", warn: "text-status-warn", nominal: "text-card-foreground", unknown: "text-status-unknown",
};
const EDGE: Record<Status, string> = {
  critical: "border-l-status-critical", warn: "border-l-status-warn", nominal: "border-l-border", unknown: "border-l-status-unknown",
};

/** 41m, 2h 14m, 5h 02m. Minutes pad once there are hours, so the column lines up. */
export const elapsed = (since: Date, now: Date) => {
  const m = Math.max(0, Math.floor((now.getTime() - since.getTime()) / 60_000));
  const h = Math.floor(m / 60);
  return h ? `${h}h ${String(m % 60).padStart(2, "0")}m` : `${m}m`;
};

export function TrackBoard({ beds, longWaitMs, now = new Date() }: Props) {
  return (
    <div className="overflow-hidden rounded-lg border bg-card">
      <Table className="text-xs">
        <TableHeader>
          <TableRow className="text-[11px] uppercase tracking-wide">
            <TableHead className="h-9">Bed</TableHead>
            <TableHead className="h-9">Patient</TableHead>
            <TableHead className="h-9">Triage</TableHead>
            <TableHead className="h-9">Waiting for</TableHead>
            <TableHead className="h-9 text-right">Elapsed</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {beds.map((bed) => {
            if (bed.state === "empty") {
              // Ready is quiet. Needs-clean is a wait of its own, and it is
              // timed, because a bed nobody has cleaned is a bed nobody can use.
              return (
                <TableRow key={bed.id} className={cn("border-l-4", bed.ready ? "border-l-border" : "border-l-state-stale")}>
                  <TableCell className="tabular-nums text-card-foreground">{bed.id}</TableCell>
                  <TableCell className={bed.ready ? "text-muted-foreground" : "text-state-stale"}>
                    {bed.ready ? "empty · ready" : "empty · needs clean"}
                  </TableCell>
                  <TableCell />
                  <TableCell />
                  <TableCell className="text-right tabular-nums text-muted-foreground">{bed.ready ? "—" : elapsed(bed.since, now)}</TableCell>
                </TableRow>
              );
            }
            const wait = now.getTime() - bed.since.getTime();
            const waitStatus: Status = wait >= longWaitMs ? "critical" : "nominal";
            const triageStatus = TRIAGE_STATUS[bed.triage];
            const row = waitStatus === "critical" ? "critical" : triageStatus;
            return (
              <TableRow key={bed.id} className={cn("border-l-4", EDGE[row])}>
                <TableCell className="tabular-nums text-card-foreground">{bed.id}</TableCell>
                {/* Initials only. The board hangs where visitors stand. */}
                <TableCell className="text-card-foreground">{bed.patient}</TableCell>
                <TableCell className={cn("tabular-nums", TEXT[triageStatus])}>{bed.triage}</TableCell>
                <TableCell className="text-card-foreground">{bed.waitingFor}</TableCell>
                <TableCell className={cn("text-right tabular-nums", TEXT[waitStatus])}>{elapsed(bed.since, now)}</TableCell>
              </TableRow>
            );
          })}
        </TableBody>
      </Table>
    </div>
  );
}

demo.tsxHow it is called: six beds, a fixed clock, a two-hour long-wait threshold.

import { TrackBoard, type Bed } from "./TrackBoard";

/**
 * Six beds on one bay. Two are empty, one of those still needs a clean.
 * The clock is fixed so the elapsed column renders the same on the server
 * and in the browser.
 */
const NOW = new Date("2026-09-15T09:00:00Z");
const ago = (minutes: number) => new Date(NOW.getTime() - minutes * 60_000);

const BEDS: Bed[] = [
  { id: "A1", state: "occupied", patient: "R. M.", triage: 1, waitingFor: "CT result", since: ago(134) },
  { id: "A2", state: "occupied", patient: "J. O.", triage: 2, waitingFor: "bloods", since: ago(41) },
  { id: "A3", state: "empty", ready: true },
  { id: "A4", state: "occupied", patient: "S. K.", triage: 3, waitingFor: "bed on ward 7", since: ago(302) },
  { id: "A5", state: "occupied", patient: "D. A.", triage: 2, waitingFor: "senior review", since: ago(28) },
  { id: "A6", state: "empty", ready: false, since: ago(12) },
];

export default function Demo() {
  return <TrackBoard beds={BEDS} longWaitMs={2 * 60 * 60_000} now={NOW} />;
}
What it renders. Identical markup in all three panes, with only the token values changing.

Examples

No screenshots yet for this pattern. Browse Products to see other examples.