Skip to content
design
Components

Data table

Rows of records in columns that the crew sort, filter, page through and select.

On this page
Columns
Note
Alarm
Rotterdam Express
0.3
8
Crossing from port and the give-way vessel, with no change of course in six minutes. Call her on VHF 16.
Warning
Elbe Spirit
0.7
17
Laden, draft 12.4 m. Overtaking down our starboard side.
Warning
Saint-Malo Ferry
0.9
22
Scheduled crossing; holds her course and speed.
Caution
Smit Tug 7
1.1
28
Towing a barge on a tow longer than 200 m. Pass astern of the barge, never between tug and tow.
Caution
Ar Men
1.4
35
Trawling with gear out about 300 m astern, restricted in her ability to maneuver.
Rows 1–5 of 9
1 target selected
The AIS target list off Ushant: sorted by alert and then by CPA, a search field and column filters, a checkbox per target, five targets a page and a Columns control that shows MMSI, type, SOG and the last-seen time. Shift and a press on a heading adds it to the sort; type a name nothing matches to see the no-match state.
Code
import { AisTargetRow, type AisShipType } from "@bosunkit/components/ais-target-row";
import { Badge } from "@bosunkit/components/badge";
import { DataTable, type Column, type ColumnSort } from "@bosunkit/components/data-table";
import { priorityBadgeVariant, type Priority } from "@bosunkit/components/priority";
import { Fish, Sailboat, Ship, ShipWheel, type LucideIcon } from "lucide-react";
import { useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "The AIS target list at 14:02 UTC off Ushant: targets sorted by alert and then by CPA, a search field over every " +
  "column, an alert and a vessel-name filter, checkboxes for a set of targets, five targets a page, a Columns " +
  "control that shows MMSI, type (with its own filter), SOG and the last-seen time, ship-type icons, priority " +
  "badges and wrapped notes. Type a name nothing matches to see the no-match state.";

type ShipType = "Cargo" | "Tanker" | "Passenger" | "Fishing" | "Sailing" | "Tug";

const TYPE_ICONS: Readonly<Record<ShipType, LucideIcon>> = {
  Cargo: Ship,
  Tanker: Ship,
  Passenger: Ship,
  Fishing: Fish,
  Sailing: Sailboat,
  Tug: ShipWheel,
};

const PRIORITY_WORDS: Readonly<Record<Priority, string>> = {
  emergency: "Emergency",
  alarm: "Alarm",
  warning: "Warning",
  caution: "Caution",
  none: "No alert",
};

const PRIORITY_RANK: Readonly<Record<Priority, number>> = { emergency: 0, alarm: 1, warning: 2, caution: 3, none: 4 };

interface Target {
  readonly mmsi: string;
  readonly name: string;
  readonly type: ShipType;
  /** Closest point of approach, nautical miles; null when the target is not closing. */
  readonly cpa: number | null;
  /** Time to the closest point of approach, minutes; null when the target is not closing. */
  readonly tcpa: number | null;
  /** Speed over ground, knots. */
  readonly sog: number;
  readonly seen: Date;
  readonly note: string;
}

/** The alert a target raises: alarm inside 0.5 NM within 12 min, warning inside 1 NM within 30 min. */
function priorityOf(target: Target): Priority {
  if (target.cpa === null || target.tcpa === null) {
    return "none";
  }
  if (target.cpa < 0.5 && target.tcpa < 12) {
    return "alarm";
  }
  if (target.cpa < 1 && target.tcpa < 30) {
    return "warning";
  }
  return target.cpa < 2 ? "caution" : "none";
}

const TARGETS: readonly Target[] = [
  {
    mmsi: "244123456",
    name: "Rotterdam Express",
    type: "Cargo",
    cpa: 0.3,
    tcpa: 8,
    sog: 18.2,
    seen: new Date("2026-10-11T14:02:10Z"),
    note: "Crossing from port and the give-way vessel, with no change of course in six minutes. Call her on VHF 16.",
  },
  {
    mmsi: "235987654",
    name: "Saint-Malo Ferry",
    type: "Passenger",
    cpa: 0.9,
    tcpa: 22,
    sog: 21.5,
    seen: new Date("2026-10-11T14:01:40Z"),
    note: "Scheduled crossing; holds her course and speed.",
  },
  {
    mmsi: "227112233",
    name: "Ar Men",
    type: "Fishing",
    cpa: 1.4,
    tcpa: 35,
    sog: 3.1,
    seen: new Date("2026-10-11T13:58:20Z"),
    note: "Trawling with gear out about 300 m astern, restricted in her ability to maneuver.",
  },
  {
    mmsi: "211445566",
    name: "Elbe Spirit",
    type: "Tanker",
    cpa: 0.7,
    tcpa: 17,
    sog: 12,
    seen: new Date("2026-10-11T14:00:50Z"),
    note: "Laden, draft 12.4 m. Overtaking down our starboard side.",
  },
  {
    mmsi: "244556677",
    name: "Windgrazer",
    type: "Sailing",
    cpa: 2.8,
    tcpa: 51,
    sog: 6.4,
    seen: new Date("2026-10-11T14:02:00Z"),
    note: "Same passage, two miles ahead.",
  },
  {
    mmsi: "244990011",
    name: "Smit Tug 7",
    type: "Tug",
    cpa: 1.1,
    tcpa: 28,
    sog: 9.2,
    seen: new Date("2026-10-11T14:01:10Z"),
    note: "Towing a barge on a tow longer than 200 m. Pass astern of the barge, never between tug and tow.",
  },
  {
    mmsi: "538009911",
    name: "Pacific Trader",
    type: "Cargo",
    cpa: 4.2,
    tcpa: 63,
    sog: 14.8,
    seen: new Date("2026-10-11T13:55:30Z"),
    note: "",
  },
  {
    mmsi: "316002244",
    name: "Northern Light",
    type: "Sailing",
    cpa: null,
    tcpa: null,
    sog: 5.8,
    seen: new Date("2026-10-11T13:51:00Z"),
    note: "Opening; no closest approach ahead.",
  },
  {
    mmsi: "228334455",
    name: "Belle-Île Express",
    type: "Passenger",
    cpa: 3,
    tcpa: 44,
    sog: 16,
    seen: new Date("2026-10-11T13:49:40Z"),
    note: "Bound for Le Palais.",
  },
];

/** A time of day as the bridge writes it: hours and minutes, with its reference. */
function utcTime(date: Date): string {
  return `${String(date.getUTCHours()).padStart(2, "0")}:${String(date.getUTCMinutes()).padStart(2, "0")} UTC`;
}

/** Each ship type as the AIS target row draws it. */
const AIS_SHIP_TYPES: Readonly<Record<ShipType, AisShipType>> = {
  Cargo: "cargo",
  Tanker: "tanker",
  Passenger: "passenger",
  Fishing: "fishing",
  Sailing: "sailing",
  Tug: "tug",
};

/** Each target's course over ground in degrees true, by MMSI, for the row of the target a press opens. */
const COURSES: Readonly<Record<string, number>> = {
  "244123456": 95,
  "235987654": 318,
  "227112233": 240,
  "211445566": 22,
  "244556677": 210,
  "244990011": 75,
  "538009911": 12,
  "316002244": 300,
  "228334455": 182,
};

/** The list's time, 14:02:30 UTC, from which a target's report age is counted. */
const LIST_TIME: number = Date.UTC(2026, 9, 11, 14, 2, 30);

const ROWS: readonly Record<string, unknown>[] = TARGETS.map(
  (target: Target): Record<string, unknown> => ({ ...target, priority: priorityOf(target) }),
);

const nameCell: CSSProperties = { display: "inline-flex", alignItems: "center", gap: "8px" };

const COLUMNS: readonly Column[] = [
  {
    key: "priority",
    label: "Alert",
   
    sortable: (a: Record<string, unknown>, b: Record<string, unknown>): number =>
      PRIORITY_RANK[a.priority as Priority] - PRIORITY_RANK[b.priority as Priority],
    filter: "option",
    filterOptions: (["alarm", "warning", "caution", "none"] as const).map(
      (priority: Priority): { value: string; label: string } => ({ value: priority, label: PRIORITY_WORDS[priority] }),
    ),
    render: (row: Record<string, unknown>): ReactElement => {
      const priority: Priority = row.priority as Priority;
      return (
        <Badge variant={priorityBadgeVariant[priority]}>{PRIORITY_WORDS[priority]}</Badge>
      );
    },
  },
  {
    key: "name",
    label: "Vessel",
    sortable: true,
    filter: "text",
    render: (row: Record<string, unknown>): ReactElement => {
      const Icon: LucideIcon = TYPE_ICONS[row.type as ShipType];
      return (
        <span style={nameCell}>
          <Icon size={14} aria-hidden="true" />
          {String(row.name)}
        </span>
      );
    },
  },
  { key: "type", label: "Type", sortable: true, filter: "option" },
  { key: "mmsi", label: "MMSI" },
  { key: "cpa", label: "CPA (NM)", align: "end", sortable: true },
  { key: "tcpa", label: "TCPA (min)", align: "end", sortable: true },
  { key: "sog", label: "SOG (kn)", align: "end", sortable: true },
  {
    key: "seen",
    label: "Last seen",
   
    sortable: true,
    sortDescendingFirst: true,
    render: (row: Record<string, unknown>): string => utcTime(row.seen as Date),
  },
  { key: "note", label: "Note", wrap: true },
];

const SORTING: readonly ColumnSort[] = [
  { key: "priority", direction: "ascending" },
  { key: "cpa", direction: "ascending" },
];

const status: CSSProperties = {
  display: "flex",
  gap: "16px",
  marginTop: "8px",
  fontSize: "13px",
  color: "var(--text-dim)",
};

const detail: CSSProperties = {
  margin: "8px 0 0",
  padding: 0,
  border: "1px solid var(--border)",
  borderRadius: "var(--radius-md)",
  overflow: "hidden",
};

export default function Showcase(): ReactElement {
  const [selected, setSelected]: [readonly string[], Dispatch<SetStateAction<readonly string[]>>] = useState<
    readonly string[]
  >(["244123456"]);
  const [opened, setOpened]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
  const openedTarget: Target | undefined = TARGETS.find((target: Target): boolean => target.mmsi === opened);

  return (
    <div style={{ width: "100%" }}>
      <DataTable
        columns={COLUMNS}
        rows={ROWS}
        rowKey={(row: Record<string, unknown>): string => String(row.mmsi)}
        defaultSorting={SORTING}
        globalFilterable
        columnPicker
        defaultHiddenColumns={["mmsi", "type", "sog", "seen"]}
        pageSize={5}
        selectable
        selectedRowKeys={selected}
        onSelectedRowKeysChange={setSelected}
        onRowClick={(row: Record<string, unknown>): void => {
          setOpened(String(row.mmsi));
        }}
      />
      <div style={status} aria-live="polite">
        <span>{selected.length === 1 ? "1 target selected" : `${String(selected.length)} targets selected`}</span>
      </div>
      {openedTarget === undefined ? null : (
        <ul style={detail} aria-label="Opened target">
          <AisTargetRow
            name={openedTarget.name}
            mmsi={openedTarget.mmsi}
            shipType={AIS_SHIP_TYPES[openedTarget.type]}
            cpa={openedTarget.cpa}
            tcpa={openedTarget.tcpa}
            sog={openedTarget.sog}
            cog={COURSES[openedTarget.mmsi] ?? null}
            cpaLimit={0.5}
            tcpaLimit={12}
            ageSeconds={(LIST_TIME - openedTarget.seen.getTime()) / 1000}
          />
        </ul>
      )}
    </div>
  );
}

A data table shows records as rows under column headings: the ship's log, the alert history, the maintenance tasks, the AIS targets in range. A cell shows its row's field for the column, or what the column's render draws from the row, and stays empty when the field is missing. Everything else is opt-in, column by column or for the whole table: sorting on a heading, a search field over every column, a filter under a heading, pages, a checkbox per row, a Columns control, and a height past which the rows scroll under the heading row. Each piece of state the crew change (the sort, the filters, the page, the selection, the hidden columns) is held by the table, or by the caller through a value and its on…Change callback.

01The most urgent row firstA table of alerts or targets opens sorted by what the crew act on: priority, then CPA or the due date. A sort the crew choose lasts until they change it or the page reloads.
02A filter says what it hidesWhen the filters keep no row, the table names the text and the column filters that hid them and offers to clear them, so an empty table never reads as no targets or no alerts.
03Every control by keyboard and by nameA sortable heading is a button and its column header carries aria-sort; every filter, checkbox and page button has a name a screen reader reads out.

When to sort, filter and page

Sort a column whose order means something: a time, a distance, a count, a priority. Give a column that is read newest or largest first sortDescendingFirst, so its first press shows what matters. A column whose values are text in an order of their own, like alert priorities, takes a compare function. A missing value sorts last in both directions, so a target with no CPA never rises above the closest one.

Filter a table the crew search more than they scan. The search field over every column suits the log and the alert history, where the crew look for a word. A column filter suits a column with a few known values (priority, system, vessel) as an option list, or a column of names or messages as text.

Page a table that grows without end, like the log or the alert history: the page holds a fixed number of rows, and a change to the sort or a filter returns to the first page. Keep a live table that changes every few seconds, like the AIS list, on one page with maxHeight when it is long, so a row does not jump to another page as its CPA changes.

Examples

Passages

Vessel
Skipper
Ended
Biscay crossing
Althea
Mathias
2026-09-01
2026-09-05
Solent day sail
Althea
Ana
2026-08-12
Two passages in a table whose Name and Started headings sort the rows on a press; the second has no end date.
Code
import { DataTable } from "@bosunkit/components/data-table";
import type { ReactElement } from "react";

export const description: string =
  "Two passages in a table whose Name and Started headings sort the rows on a press; the second has no end date.";

export default function Passages(): ReactElement {
  return (
    <DataTable
      columns={[
        { key: "name", label: "Name", sortable: true },
        { key: "vessel_name", label: "Vessel" },
        { key: "skipper_name", label: "Skipper" },
        { key: "started_at", label: "Started", sortable: true },
        { key: "ended_at", label: "Ended" },
      ]}
      rows={[
        {
          name: "Biscay crossing",
          vessel_name: "Althea",
          skipper_name: "Mathias",
          started_at: "2026-09-01",
          ended_at: "2026-09-05",
        },
        {
          name: "Solent day sail",
          vessel_name: "Althea",
          skipper_name: "Ana",
          started_at: "2026-08-12",
          ended_at: "",
        },
      ]}
    />
  );
}

Sortable columns

A press on a sortable heading sorts ascending, a second descending, a third returns the given order. Numbers and dates compare by value, text in the crew's language with numbers inside it by value.

Note
06:00 UTC
1841.5
82
Start of watch
10:00 UTC
1845.5
88
Motoring in a calm
08:00 UTC
1843.5
Sensor dropped out
12:00 UTC
1847.5
79
Sails up, engine off
An engine log whose Time, Hours and Coolant headings sort on a press: times and numbers by value, newest time first, and the reading with no coolant temperature last in both directions.
Code
import { DataTable } from "@bosunkit/components/data-table";
import type { ReactElement } from "react";

export const description: string =
  "An engine log whose Time, Hours and Coolant headings sort on a press: times and numbers by value, newest time " +
  "first, and the reading with no coolant temperature last in both directions.";

/** A time of day as the bridge writes it: hours and minutes, with its reference. */
function utcTime(date: Date): string {
  return `${String(date.getUTCHours()).padStart(2, "0")}:${String(date.getUTCMinutes()).padStart(2, "0")} UTC`;
}

export default function Sorting(): ReactElement {
  return (
    <DataTable
      columns={[
        {
          key: "at",
          label: "Time",
         
          sortable: true,
          sortDescendingFirst: true,
          render: (row: Record<string, unknown>): string => utcTime(row.at as Date),
        },
        { key: "hours", label: "Engine hours", align: "end", sortable: true },
        { key: "coolant", label: "Coolant (°C)", align: "end", sortable: true },
        { key: "note", label: "Note" },
      ]}
      rows={[
        { at: new Date("2026-10-11T06:00:00Z"), hours: 1841.5, coolant: 82, note: "Start of watch" },
        { at: new Date("2026-10-11T10:00:00Z"), hours: 1845.5, coolant: 88, note: "Motoring in a calm" },
        { at: new Date("2026-10-11T08:00:00Z"), hours: 1843.5, coolant: null, note: "Sensor dropped out" },
        { at: new Date("2026-10-11T12:00:00Z"), hours: 1847.5, coolant: 79, note: "Sails up, engine off" },
      ]}
    />
  );
}

Sort by several columns

defaultSorting starts the table sorted. Shift and a press adds a column to the sort or turns it where it stands; the column header of the first sort key carries aria-sort, and each sorted heading describes its place.

Task
Engine
Replace the raw-water impeller
2026-10-14
1850
Engine
Change the oil and filter
2026-10-20
1900
Rigging
Lubricate the furler
2026-10-18
Rigging
Check the forestay toggle
2026-11-02
Safety
Replace the EPIRB battery
2026-12-15
Safety
Service the life raft
2027-03-01
Maintenance tasks sorted by system and then by due date. A press on a heading sorts by that column alone; Shift and a press adds it to the sort, and the small number beside each arrow is its place.
Code
import { DataTable } from "@bosunkit/components/data-table";
import type { ReactElement } from "react";

export const description: string =
  "Maintenance tasks sorted by system and then by due date. A press on a heading sorts by that column alone; " +
  "Shift and a press adds it to the sort, and the small number beside each arrow is its place.";

export default function MultiSort(): ReactElement {
  return (
    <DataTable
      columns={[
        { key: "system", label: "System", sortable: true },
        { key: "task", label: "Task" },
        { key: "due", label: "Due", sortable: true },
        { key: "hours", label: "Due at (h)", align: "end", sortable: true },
      ]}
      defaultSorting={[
        { key: "system", direction: "ascending" },
        { key: "due", direction: "ascending" },
      ]}
      rows={[
        { system: "Engine", task: "Change the oil and filter", due: "2026-10-20", hours: 1900 },
        { system: "Rigging", task: "Check the forestay toggle", due: "2026-11-02", hours: null },
        { system: "Engine", task: "Replace the raw-water impeller", due: "2026-10-14", hours: 1850 },
        { system: "Safety", task: "Service the life raft", due: "2027-03-01", hours: null },
        { system: "Rigging", task: "Lubricate the furler", due: "2026-10-18", hours: null },
        { system: "Safety", task: "Replace the EPIRB battery", due: "2026-12-15", hours: null },
      ]}
    />
  );
}

Search over every column

Time
By
Entry
06:00 UTC
Mathias
Took the watch. Wind SW 14 kn, sea moderate, barometer 1012 hPa.
07:30 UTC
Ana
Put in the first reef, gusts to 24 kn.
09:10 UTC
Ana
Ushant lighthouse abeam to port, 9 NM.
11:45 UTC
Mathias
Shook out the reef; wind easing to 12 kn.
13:20 UTC
Joe
Fishing vessel crossed ahead, passed clear astern of her.
The ship's log with a search field above it: typing keeps the entries whose text in any column contains what was typed, ignoring case. Try reef or Ana.
Code
import { DataTable } from "@bosunkit/components/data-table";
import type { ReactElement } from "react";

export const description: string =
  "The ship's log with a search field above it: typing keeps the entries whose text in any column contains what " +
  "was typed, ignoring case. Try reef or Ana.";

export default function GlobalFilter(): ReactElement {
  return (
    <DataTable
      globalFilterable
      columns={[
        { key: "time", label: "Time" },
        { key: "author", label: "By" },
        { key: "entry", label: "Entry", wrap: true },
      ]}
      rows={[
        { time: "06:00 UTC", author: "Mathias", entry: "Took the watch. Wind SW 14 kn, sea moderate, barometer 1012 hPa." },
        { time: "07:30 UTC", author: "Ana", entry: "Put in the first reef, gusts to 24 kn." },
        { time: "09:10 UTC", author: "Ana", entry: "Ushant lighthouse abeam to port, 9 NM." },
        { time: "11:45 UTC", author: "Mathias", entry: "Shook out the reef; wind easing to 12 kn." },
        { time: "13:20 UTC", author: "Joe", entry: "Fishing vessel crossed ahead, passed clear astern of her." },
      ]}
    />
  );
}

Column filters

An option filter lists the column's distinct values in order, or the filterOptions the column gives with their labels; a text filter keeps the cells that contain the typed text.

Priority
Source
Message
Raised
Alarm
Anchor watch
Anchor dragging, 38 m outside the swing circle
02:14 UTC
Warning
Bilge
Aft bilge pump running for 4 min
03:40 UTC
Caution
Battery
House bank at 12.1 V
05:05 UTC
Warning
Engine
Coolant at 96 °C and rising
09:12 UTC
Caution
Depth
Depth 6.2 m, under the 7 m shallow limit
11:30 UTC
Alarm
AIS
Rotterdam Express, CPA 0.3 NM in 8 min
14:02 UTC
The alert history with a filter under three headings: a list of priorities by name, a list of the sources found in the rows, and a search field over the message. Every filter must keep a row for it to show.
Code
import { Badge } from "@bosunkit/components/badge";
import { DataTable } from "@bosunkit/components/data-table";
import { priorityBadgeVariant, type Priority } from "@bosunkit/components/priority";
import type { ReactElement } from "react";

export const description: string =
  "The alert history with a filter under three headings: a list of priorities by name, a list of the sources " +
  "found in the rows, and a search field over the message. Every filter must keep a row for it to show.";

const PRIORITY_WORDS: Readonly<Record<Priority, string>> = {
  emergency: "Emergency",
  alarm: "Alarm",
  warning: "Warning",
  caution: "Caution",
  none: "No alert",
};

export default function ColumnFilters(): ReactElement {
  return (
    <DataTable
      columns={[
        {
          key: "priority",
          label: "Priority",
         
          filter: "option",
          filterOptions: (["alarm", "warning", "caution"] as const).map(
            (priority: Priority): { value: string; label: string } => ({
              value: priority,
              label: PRIORITY_WORDS[priority],
            }),
          ),
          render: (row: Record<string, unknown>): ReactElement => {
            const priority: Priority = row.priority as Priority;
            return <Badge variant={priorityBadgeVariant[priority]}>{PRIORITY_WORDS[priority]}</Badge>;
          },
        },
        { key: "source", label: "Source", filter: "option" },
        { key: "message", label: "Message", filter: "text", wrap: true },
        { key: "time", label: "Raised" },
      ]}
      rows={[
        { priority: "alarm", source: "Anchor watch", message: "Anchor dragging, 38 m outside the swing circle", time: "02:14 UTC" },
        { priority: "warning", source: "Bilge", message: "Aft bilge pump running for 4 min", time: "03:40 UTC" },
        { priority: "caution", source: "Battery", message: "House bank at 12.1 V", time: "05:05 UTC" },
        { priority: "warning", source: "Engine", message: "Coolant at 96 °C and rising", time: "09:12 UTC" },
        { priority: "caution", source: "Depth", message: "Depth 6.2 m, under the 7 m shallow limit", time: "11:30 UTC" },
        { priority: "alarm", source: "AIS", message: "Rotterdam Express, CPA 0.3 NM in 8 min", time: "14:02 UTC" },
      ]}
    />
  );
}

No rows match

Passage
Vessel
Started
No rows match “Lisbon”, Vessel “Althea”.
A passage list filtered to nothing: the table names the text and the column filter that hid every row, and Clear filters brings the rows back.
Code
import { DataTable } from "@bosunkit/components/data-table";
import type { ReactElement } from "react";

export const description: string =
  "A passage list filtered to nothing: the table names the text and the column filter that hid every row, and " +
  "Clear filters brings the rows back.";

export default function NoMatch(): ReactElement {
  return (
    <DataTable
      globalFilterable
      defaultGlobalFilter="Lisbon"
      defaultColumnFilters={[{ key: "vessel", value: "Althea" }]}
      columns={[
        { key: "name", label: "Passage" },
        { key: "vessel", label: "Vessel", filter: "option" },
        { key: "started", label: "Started" },
      ]}
      rows={[
        { name: "Biscay crossing", vessel: "Althea", started: "2026-09-01" },
        { name: "Solent day sail", vessel: "Althea", started: "2026-08-12" },
        { name: "Falmouth to Porto", vessel: "Windgrazer", started: "2026-07-20" },
      ]}
    />
  );
}

No rows

empty is what the table shows with no rows at all, whatever the filters. It says what the empty table means for this table and what to check.

Vessel
CPA (NM)
TCPA (min)
No AIS targets in range. If a vessel is in sight, check that the AIS receiver is on and has power.
An AIS target list with no targets: the table shows the caller's text, which says what the empty list means and what to check, apart from the no-match state a filter gives.
Code
import { DataTable } from "@bosunkit/components/data-table";
import type { ReactElement } from "react";

export const description: string =
  "An AIS target list with no targets: the table shows the caller's text, which says what the empty list means " +
  "and what to check, apart from the no-match state a filter gives.";

export default function NoRows(): ReactElement {
  return (
    <DataTable
      globalFilterable
      columns={[
        { key: "name", label: "Vessel" },
        { key: "cpa", label: "CPA (NM)", align: "end" },
        { key: "tcpa", label: "TCPA (min)", align: "end" },
      ]}
      rows={[]}
      empty="No AIS targets in range. If a vessel is in sight, check that the AIS receiver is on and has power."
    />
  );
}

Pages

Wind
On watch
00:00 UTC
1240
SW 12 kn
Mathias
01:00 UTC
1246
SW 14 kn
Mathias
02:00 UTC
1252
WSW 16 kn
Mathias
03:00 UTC
1258
W 18 kn
Mathias
04:00 UTC
1264
W 15 kn
Ana
Rows 1–5 of 23
A day of hourly log entries, five to a page, with the range and the page buttons under the table. Sorting or filtering goes back to the first page.
Code
import { DataTable } from "@bosunkit/components/data-table";
import type { ReactElement } from "react";

export const description: string =
  "A day of hourly log entries, five to a page, with the range and the page buttons under the table. Sorting or " +
  "filtering goes back to the first page.";

const WINDS: readonly string[] = ["SW 12 kn", "SW 14 kn", "WSW 16 kn", "W 18 kn", "W 15 kn", "WNW 11 kn"];

const ROWS: readonly Record<string, unknown>[] = Array.from(
  { length: 23 },
  (_value: unknown, hour: number): Record<string, unknown> => ({
    time: `${String(hour).padStart(2, "0")}:00 UTC`,
    log: 1240 + hour * 6,
    wind: WINDS[hour % WINDS.length],
    watch: hour % 8 < 4 ? "Mathias" : "Ana",
  }),
);

export default function Pages(): ReactElement {
  return (
    <DataTable
      pageSize={5}
      columns={[
        { key: "time", label: "Time", sortable: true },
        { key: "log", label: "Log (NM)", align: "end", sortable: true },
        { key: "wind", label: "Wind" },
        { key: "watch", label: "On watch", filter: "option" },
      ]}
      rows={ROWS}
    />
  );
}

Selection

rowKey keeps a row's selection with the row through a sort and a new set of rows. The select-all box selects every row the filters keep, on every page.

Task
System
Change the oil and filter
Engine
2026-10-20
Replace the raw-water impeller
Engine
2026-10-14
Lubricate the furler
Rigging
2026-10-18
Service the life raft
Safety
2027-03-01
Maintenance tasks with a checkbox on each row and one that selects every task the filter keeps. The page holds the selection and offers an action for the tasks selected.
Code
import { Button } from "@bosunkit/components/button";
import { DataTable } from "@bosunkit/components/data-table";
import { useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "Maintenance tasks with a checkbox on each row and one that selects every task the filter keeps. The page holds " +
  "the selection and offers an action for the tasks selected.";

const bar: CSSProperties = { display: "flex", alignItems: "center", gap: "12px", marginTop: "8px" };

export default function Selection(): ReactElement {
  const [selected, setSelected]: [readonly string[], Dispatch<SetStateAction<readonly string[]>>] = useState<
    readonly string[]
  >(["impeller"]);

  return (
    <div style={{ width: "100%" }}>
      <DataTable
        selectable
        rowKey={(row: Record<string, unknown>): string => String(row.id)}
        selectedRowKeys={selected}
        onSelectedRowKeysChange={setSelected}
        columns={[
          { key: "task", label: "Task" },
          { key: "system", label: "System", filter: "option" },
          { key: "due", label: "Due", sortable: true },
        ]}
        rows={[
          { id: "oil", task: "Change the oil and filter", system: "Engine", due: "2026-10-20" },
          { id: "impeller", task: "Replace the raw-water impeller", system: "Engine", due: "2026-10-14" },
          { id: "furler", task: "Lubricate the furler", system: "Rigging", due: "2026-10-18" },
          { id: "raft", task: "Service the life raft", system: "Safety", due: "2027-03-01" },
        ]}
      />
      <div style={bar}>
        <Button
          size="sm"
          disabled={selected.length === 0}
          onClick={(): void => {
            setSelected([]);
          }}
        >
          {selected.length === 1 ? "Mark 1 task done" : `Mark ${String(selected.length)} tasks done`}
        </Button>
      </div>
    </div>
  );
}

Columns

Columns
Vessel
SOG (kn)
Rotterdam Express
0.3
8
18.2
Saint-Malo Ferry
0.9
22
21.5
Ar Men
1.4
35
3.1
An AIS target list with a Columns control that lists every column; MMSI and course start hidden, and the last column shown cannot be hidden.
Code
import { DataTable } from "@bosunkit/components/data-table";
import type { ReactElement } from "react";

export const description: string =
  "An AIS target list with a Columns control that lists every column; MMSI and course start hidden, and the last " +
  "column shown cannot be hidden.";

export default function Columns(): ReactElement {
  return (
    <DataTable
      columnPicker
      defaultHiddenColumns={["mmsi", "cog"]}
      columns={[
        { key: "name", label: "Vessel" },
        { key: "mmsi", label: "MMSI" },
        { key: "cpa", label: "CPA (NM)", align: "end", sortable: true },
        { key: "tcpa", label: "TCPA (min)", align: "end", sortable: true },
        { key: "sog", label: "SOG (kn)", align: "end" },
        { key: "cog", label: "COG (°)", align: "end" },
      ]}
      rows={[
        { name: "Rotterdam Express", mmsi: "244123456", cpa: 0.3, tcpa: 8, sog: 18.2, cog: "074" },
        { name: "Saint-Malo Ferry", mmsi: "235987654", cpa: 0.9, tcpa: 22, sog: 21.5, cog: "152" },
        { name: "Ar Men", mmsi: "227112233", cpa: 1.4, tcpa: 35, sog: 3.1, cog: "301" },
      ]}
    />
  );
}

Scrolling under the heading row

Time
Position
14:00 UTC
12
48°27.4'N 004°51.2'W
14:03 UTC
11
48°28.4'N 004°50.2'W
14:06 UTC
10
48°29.4'N 004°49.2'W
14:09 UTC
9.7
48°30.4'N 004°48.2'W
14:12 UTC
8.8
48°31.4'N 004°47.2'W
14:15 UTC
12
48°32.4'N 004°46.2'W
14:18 UTC
11
48°33.4'N 004°45.2'W
14:21 UTC
10
48°34.4'N 004°44.2'W
14:24 UTC
9.7
48°35.4'N 004°43.2'W
14:27 UTC
8.8
48°36.4'N 004°42.2'W
14:30 UTC
12
48°37.4'N 004°41.2'W
14:33 UTC
11
48°38.4'N 004°40.2'W
14:36 UTC
10
48°39.4'N 004°39.2'W
14:39 UTC
9.7
48°40.4'N 004°38.2'W
14:42 UTC
8.8
48°41.4'N 004°37.2'W
14:45 UTC
12
48°42.4'N 004°36.2'W
14:48 UTC
11
48°43.4'N 004°35.2'W
14:51 UTC
10
48°44.4'N 004°34.2'W
14:54 UTC
9.7
48°45.4'N 004°33.2'W
14:57 UTC
8.8
48°46.4'N 004°32.2'W
Twenty soundings in a table capped at 15rem: the rows scroll inside it and the heading row stays at the top.
Code
import { DataTable } from "@bosunkit/components/data-table";
import type { ReactElement } from "react";

export const description: string =
  "Twenty soundings in a table capped at 15rem: the rows scroll inside it and the heading row stays at the top.";

const ROWS: readonly Record<string, unknown>[] = Array.from(
  { length: 20 },
  (_value: unknown, index: number): Record<string, unknown> => ({
    time: `14:${String(index * 3).padStart(2, "0")} UTC`,
    depth: [12, 11, 10, 9.7, 8.8][index % 5],
    position: `48°${String(27 + index)}.4'N 004°${String(51 - index)}.2'W`,
  }),
);

export default function Scrolling(): ReactElement {
  return (
    <DataTable
      maxHeight="15rem"
      columns={[
        { key: "time", label: "Time" },
        { key: "depth", label: "Depth (m)", align: "end", sortable: true },
        { key: "position", label: "Position" },
      ]}
      rows={ROWS}
    />
  );
}

Cell layout

Each column takes the width its heading and values need, so a CPA or a time stays narrow and whole. A column marked wrap takes the width left over and breaks its text onto more lines; a table wider than its frame scrolls sideways. Every cell starts at the top of its row, with the checkbox on the first line.

Category
Note
Fuel
182.4
Diesel at Valletta, 98 L; the pump stopped twice at the same reading.
Berth
64
One night in Marina di Ragusa.
Repair
1240
New raw-water pump fitted in Licata after the old one wept from the shaft seal. The yard kept the old pump.
Cells drawn from their rows: an icon beside each category, amounts aligned at the end so their digits line up, and a note column whose text wraps onto as many lines as it needs.
Code
import { DataTable } from "@bosunkit/components/data-table";
import { Anchor, Fuel, Wrench, type LucideIcon } from "lucide-react";
import type { CSSProperties, ReactElement } from "react";

export const description: string =
  "Cells drawn from their rows: an icon beside each category, amounts aligned at the end so their digits line up, " +
  "and a note column whose text wraps onto as many lines as it needs.";

const ICONS: Readonly<Record<string, LucideIcon>> = { Fuel: Fuel, Berth: Anchor, Repair: Wrench };

const category: CSSProperties = { display: "inline-flex", alignItems: "center", gap: "8px" };

export default function CellLayout(): ReactElement {
  return (
    <DataTable
      columns={[
        {
          key: "category",
          label: "Category",
         
          render: (row: Record<string, unknown>): ReactElement => {
            const Icon: LucideIcon = ICONS[String(row.category)] ?? Wrench;
            return (
              <span style={category}>
                <Icon size={14} aria-hidden="true" />
                {String(row.category)}
              </span>
            );
          },
        },
        { key: "amount", label: "Amount (EUR)", align: "end", sortable: true },
        { key: "note", label: "Note", wrap: true },
      ]}
      rows={[
        { category: "Fuel", amount: 182.4, note: "Diesel at Valletta, 98 L; the pump stopped twice at the same reading." },
        { category: "Berth", amount: 64, note: "One night in Marina di Ragusa." },
        {
          category: "Repair",
          amount: 1240,
          note: "New raw-water pump fitted in Licata after the old one wept from the shaft seal. The yard kept the old pump.",
        },
      ]}
    />
  );
}
Do
Passage
Started
No rows match “Lisbon”.
Name what hid the rows. The crew read which filter to clear and know the rows are there.
Don't
Passage
Started
Don't show a filtered-out table as an empty one. A bare empty list reads as no traffic in range.
Do
Vessel
CPA (NM)
Rotterdam Express
0.3
Pacific Trader
12.5
Align numbers at the end, so the digits of a CPA or a depth line up down the column.
Don't
Vessel
CPA (NM)
Rotterdam Express
0.3
Pacific Trader
12.5
Don't leave numbers at the start of the column, where 0.3 and 12.5 start in the same place.

Props

PropTypeDefaultDescription
columnsrequiredreadonly Column[]–The columns, in order.
rowsrequiredreadonly Record<string, unknown>[]–The rows; a cell shows its column's field, empty when the field is missing.
onRowClick((row: Record<string, unknown>) => void) | undefined–Called with a row when the crew press it, or press Enter or Space on it. Rows are not pressable when unset.
sortingreadonly ColumnSort[] | undefined–The columns the rows are sorted by, first column first, when the caller holds the sort; the table then shows this sort and reports a press through onSortingChange. A column that is not sortable is left out.
defaultSortingreadonly ColumnSort[] | undefinedNO_SORTINGThe sort the table starts with when it holds the sort itself.
onSortingChange((sorting: readonly ColumnSort[]) => void) | undefined–Called with the new sort when the crew press a heading: Shift adds the column to the sort or turns it.
globalFilterableboolean | undefinedfalsePuts a field above the table that keeps the rows whose text in any column contains the typed text.
searchRefRef<HTMLInputElement> | undefined–The field above the table, for a caller that focuses it, such as on a key the page gives it.
searchKeyShortcutsstring | undefined–The keys that focus the field above the table, as aria-keyshortcuts names them ("/"); the caller handles them.
globalFilterstring | undefined–The text of the field above the table, when the caller holds it; pair it with onGlobalFilterChange.
defaultGlobalFilterstring | undefined""The text the field above the table starts with when the table holds it itself.
onGlobalFilterChange((value: string) => void) | undefined–Called with the text of the field above the table as the crew type.
columnFiltersreadonly ColumnFilter[] | undefined–The column filters, when the caller holds them; pair it with onColumnFiltersChange.
defaultColumnFiltersreadonly ColumnFilter[] | undefinedNO_FILTERSThe column filters the table starts with when it holds them itself.
onColumnFiltersChange((filters: readonly ColumnFilter[]) => void) | undefined–Called with every column filter that is set when the crew change one; a cleared filter is left out.
emptyReactNode–What the table shows when it has no rows at all; "No rows to show." when unset.
pageSizenumber | undefined–Shows the rows that many at a time, with controls under the table to move between pages; every row at once when unset. A change to the sort or a filter goes back to the first page.
pageIndexnumber | undefined–The page shown, from 0, when the caller holds it; pair it with onPageIndexChange.
defaultPageIndexnumber | undefined0The page the table starts on when it holds the page itself.
onPageIndexChange((pageIndex: number) => void) | undefined–Called with the page to show when the crew move between pages, or when a change sends the table to page 0.
columnPickerboolean | undefinedfalsePuts a Columns control above the table that lists every column with a checkbox, so the crew can hide the columns they do not need; the last column shown cannot be hidden. A hidden column still sorts and filters the rows.
hiddenColumnsreadonly string[] | undefined–The keys of the hidden columns, when the caller holds them; pair it with onHiddenColumnsChange.
defaultHiddenColumnsreadonly string[] | undefinedNO_KEYSThe keys of the columns hidden at first when the table holds them itself.
onHiddenColumnsChange((keys: readonly string[]) => void) | undefined–Called with the keys of every hidden column when the crew tick or clear a column.
maxHeightstring | undefined–The table's greatest height, as CSS; past it the rows scroll under the heading row, which stays in view. The table grows with its rows when unset. A table wider than its frame scrolls sideways in the same box.
selectableboolean | undefinedfalsePuts a checkbox before each row and one in the heading row that selects every row the filters keep.
rowKey((row: Record<string, unknown>) => string) | undefined–A row's identity for the selection and for drawing, which stays with the row through a sort or a new set of rows; the row's place in rows when unset, which suits rows that do not change while they are shown.
selectedRowKeysreadonly string[] | undefined–The keys of the selected rows, when the caller holds them; pair it with onSelectedRowKeysChange.
defaultSelectedRowKeysreadonly string[] | undefinedNO_KEYSThe keys of the rows selected at first when the table holds the selection itself.
onSelectedRowKeysChange((keys: readonly string[]) => void) | undefined–Called with the keys of every selected row when the crew tick or clear a box. A selected row a filter hides stays selected.
classNamestring | undefined–Classes added to the outermost element: the table, or the frame that holds it with its filter field or pages.
  • Entity list: a table with an Add button and the form that edits a row.
  • Badge: the priority label a cell draws for an alert or a target.
  • Accessibility: the target sizes and the keyboard path the controls follow.

A live list, such as the AIS targets, holds its order while the crew are in the table: with focus in it or the pointer over it, an update that would re-sort the rows leaves them where they are, a new row comes in at the end, and the order applies when they leave. A sort or a filter the crew set applies at once.