Skip to content
design
Components

Event timeline

What happened, when and by whom, with each correction shown against the entry it corrects.

On this page

Saturday 17 October 2026

  1. LogLizard light abeam to port, 4.2 NM49°55.8′ N 005°12.1′ W, COG 088°, SOG 6.4 knby Ada · Skipper
  2. VoiceWind 18 kn from 230°, one reef in the mainby BertCorrected 02:25 UTC by BertWind: 15 kn → 18 knReason: Read the gust, not the mean
  3. Watch handed over from Ada to BertCourse 245°, barometer 1012 hPa and steady, traffic clear.by AdaWatch

Friday 16 October 2026

  1. Left the berth at Brixhamby Ada · Skipper
A night's log: each entry's time in UTC, who wrote it, and a correction with the value first recorded.
Code
import { EventTimeline } from "@bosunkit/components/event-timeline";
import type { ReactElement } from "react";
import { LOG } from "./log-data";

export const description: string =
  "A night passage's log over two days, newest first, with a wind entry corrected after the gust was read.";

export default function Showcase(): ReactElement {
  return <EventTimeline events={LOG} timeZone="UTC" timeReference="UTC" label="Ship's log" />;
}

An event timeline lists what happened, when and by whom: the ship's log, an alert history, a watch's handovers. It sorts its entries by time, newest first unless order says otherwise, and puts each day under a heading in the crew's language. Every time is printed with its timeReference. An entry carries what happened, more about it, who recorded it and where it came from; end takes a badge or a menu.

A correction is shown against the entry it corrects, with when and by whom, the field, the value first recorded struck through and the value it holds, and why: a record that was corrected never reads as if it had always said the new value, and a screen reader hears the change as one sentence. An alert in a history carries its priority's shape, color and word (IEC 62923), and when it was acknowledged and cleared; it never flashes. With onSelect each entry is a button, the selected one marked as current. hasEarlier and loadEarlierAction page back, the button pending until the action settles. A new entry fades in as it arrives, and nothing already listed moves.

relative writes how long ago each entry was beside its time ("12:00 UTC · 1 hr ago") within relativeWithinMs, 12 hours by default, floored; the time keeps its reference, and its tooltip writes the moment in full. An entry's actions show as icon buttons named by their label, two of them, with the rest in a "More actions" menu, and report through onAction; they show while the pointer is over the entry or focus is in it, always on a touch screen and on the selected entry, and they never select it. Its source and tags are chips, and filterable draws a row of chips for the priorities, sources and tags the entries carry: the pressed chips show the entries that match any of them.

Examples

Relative times, actions and filters

Saturday 17 October 2026

  1. LogLizard light abeam to port, 4.2 NM49°55.8′ N 005°12.1′ W, COG 088°, SOG 6.4 knby Ada · Skipper
  2. VoiceWind 18 kn from 230°, one reef in the mainby BertCorrected 02:25 UTC by BertWind: 15 kn → 18 knReason: Read the gust, not the mean
  3. Watch handed over from Ada to BertCourse 245°, barometer 1012 hPa and steady, traffic clear.by AdaWatch

Friday 16 October 2026

  1. Left the berth at Brixhamby Ada · Skipper

The page receives: nothing yet

Relative times beside each time, actions on hover or focus (correct, copy, and share in the menu), and chips that filter the log by source and tag.
Code
import { EventTimeline, type EventTimelineAction, type EventTimelineEvent } from "@bosunkit/components/event-timeline";
import { Copy, Pencil, Share2 } from "lucide-react";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
import { LOG } from "./log-data";

export const description: string =
  "Relative times beside each time, actions on hover or focus (correct, copy, and share in the menu), and chips that " +
  "filter the log by source and tag.";

const NOW: number = Date.UTC(2026, 9, 17, 8, 30);

const ACTIONS: EventTimelineAction[] = [
  { id: "correct", label: "Correct", icon: <Pencil /> },
  { id: "copy", label: "Copy", icon: <Copy /> },
  { id: "share", label: "Share", icon: <Share2 /> },
];

const EVENTS: EventTimelineEvent[] = LOG.map(
  (event: EventTimelineEvent): EventTimelineEvent => ({ ...event, actions: ACTIONS }),
);

export default function ActionsAndFilters(): ReactElement {
  const [last, setLast]: [string, Dispatch<SetStateAction<string>>] = useState<string>("nothing yet");
  return (
    <div style={{ display: "grid", gap: "var(--space-sm)" }}>
      <EventTimeline
        events={EVENTS}
        timeZone="UTC"
        timeReference="UTC"
        relative
        now={NOW}
        filterable
        onAction={(eventId: string, actionId: string): void => {
          setLast(`${actionId} on ${eventId}`);
        }}
      />
      <p style={{ margin: 0, color: "var(--text-dim)" }}>The page receives: {last}</p>
    </div>
  );
}

An alert history

  1. AlarmAnchor watchAnchor drag: 38 m from the drop pointAcknowledged 22:32 UTC by AdaCleared 22:50 UTC
  2. WarningWeatherWind rising: 24 kn, gusts 31 knAcknowledged 21:52 UTC by Ada
  3. CautionBattery monitorHouse battery at 48 %Cleared 20:30 UTC
The alerts of a night at anchor, each with its priority's shape, color and word, acknowledged and cleared.
Code
import { EventTimeline, type EventTimelineEvent } from "@bosunkit/components/event-timeline";
import type { ReactElement } from "react";

export const description: string =
  "The alerts of a night at anchor, each with its priority's shape, color and word, acknowledged and cleared.";

const NIGHT: number = Date.UTC(2026, 9, 16, 22, 30);
const MINUTE: number = 60_000;

const ALERTS: EventTimelineEvent[] = [
  {
    id: "drag",
    at: NIGHT,
    title: "Anchor drag: 38 m from the drop point",
    priority: "alarm",
    source: "Anchor watch",
    acknowledged: { at: NIGHT + 2 * MINUTE, by: "Ada" },
    cleared: { at: NIGHT + 20 * MINUTE },
  },
  {
    id: "wind",
    at: NIGHT - 40 * MINUTE,
    title: "Wind rising: 24 kn, gusts 31 kn",
    priority: "warning",
    source: "Weather",
    acknowledged: { at: NIGHT - 38 * MINUTE, by: "Ada" },
  },
  {
    id: "battery",
    at: NIGHT - 3 * 60 * MINUTE,
    title: "House battery at 48 %",
    priority: "caution",
    source: "Battery monitor",
    cleared: { at: NIGHT - 2 * 60 * MINUTE },
  },
];

export default function AlertHistory(): ReactElement {
  return <EventTimeline events={ALERTS} timeZone="UTC" timeReference="UTC" label="Alert history" groupBy="none" />;
}

Selectable entries

Friday 16 October 2026

  1. by Ada · Skipper

Saturday 17 October 2026

  1. Course 245°, barometer 1012 hPa and steady, traffic clear.by AdaWatch
  2. Voiceby BertCorrected 02:25 UTC by BertWind: 15 kn → 18 knReason: Read the gust, not the mean
  3. Log49°55.8′ N 005°12.1′ W, COG 088°, SOG 6.4 knby Ada · Skipper
Entries as buttons in a side panel, oldest first; the selected one is marked current.
Code
import { EventTimeline } from "@bosunkit/components/event-timeline";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
import { LOG } from "./log-data";

export const description: string = "Entries as buttons in a side panel, oldest first; the selected one is marked current.";

export default function Selectable(): ReactElement {
  const [selected, setSelected]: [string, Dispatch<SetStateAction<string>>] = useState<string>("wind");
  return (
    <div style={{ maxInlineSize: "28rem" }}>
      <EventTimeline
        events={LOG}
        timeZone="UTC"
        timeReference="UTC"
        order="oldest"
        dense
        selectedId={selected}
        onSelect={setSelected}
      />
    </div>
  );
}

Earlier entries

Saturday 17 October 2026

  1. LogLizard light abeam to port, 4.2 NM49°55.8′ N 005°12.1′ W, COG 088°, SOG 6.4 knby Ada · Skipper
  2. VoiceWind 18 kn from 230°, one reef in the mainby BertCorrected 02:25 UTC by BertWind: 15 kn → 18 knReason: Read the gust, not the mean
The two latest entries, and a button that loads the earlier ones from bosund.
Code
import { EventTimeline, type EventTimelineEvent } from "@bosunkit/components/event-timeline";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
import { LOG } from "./log-data";

export const description: string = "The two latest entries, and a button that loads the earlier ones from bosund.";

export default function Earlier(): ReactElement {
  const [events, setEvents]: [EventTimelineEvent[], Dispatch<SetStateAction<EventTimelineEvent[]>>] = useState<
    EventTimelineEvent[]
  >(
    LOG.slice(0, 2),
  );
  return (
    <EventTimeline
      events={events}
      timeZone="UTC"
      timeReference="UTC"
      hasEarlier={events.length < LOG.length}
      loadEarlierAction={(): Promise<void> =>
        new Promise<void>((resolve: () => void): void => {
          setTimeout((): void => {
            setEvents(LOG);
            resolve();
          }, 600);
        })
      }
    />
  );
}

Nothing recorded

Nothing recorded yet

A log with no entries: the timeline's empty state.
Code
import { EventTimeline } from "@bosunkit/components/event-timeline";
import type { ReactElement } from "react";

export const description: string = "A log with no entries: the timeline's empty state.";

export default function Empty(): ReactElement {
  return <EventTimeline events={[]} timeZone="UTC" timeReference="UTC" label="Ship's log" />;
}

Props

PropTypeDefaultDescription
eventsrequiredreadonly EventTimelineEvent[]–What happened, in any order: the timeline sorts them by at.
timeReferencerequiredstring–The reference every time is printed with, translated by the caller ("UTC", "ship's time").
timeZonestring | undefined–The time zone times and days are written in, an IANA name; the browser's unless set. Match it to the reference.
order"newest" | "oldest" | undefined"newest"newest first, as a log is read, or oldest first, as a passage is told; newest unless set.
groupBy"day" | "none" | undefined"day"day puts each day under its own heading; none lists them together. day unless set.
headingLevelNestedHeadingLevel | undefined3The heading level of the day headings; 3 unless set.
onSelect((id: string) => void) | undefined–Makes each entry a button: called with its id when the crew press it.
selectedIdstring | undefined–The entry shown as the current one, with onSelect.
onAction((eventId: string, actionId: string) => void) | undefined–Called with an entry's id and an action's id when the crew press one of the entry's actions.
relativeboolean | undefinedfalseWrites how long ago each entry was beside its time ("12:00 UTC · 1 hr ago"), floored, for entries within relativeWithinMs; the time and its reference stay printed. The timeline keeps a minute clock unless now is set.
relativeWithinMsnumber | undefined12 * HOURHow far back, in milliseconds, an entry gets its relative time; 12 hours unless set.
nownumber | undefined–A fixed current time for the relative times, for examples and printouts.
filterableboolean | undefinedfalseDraws a row of chips over the entries, one for each priority, source and tag they carry: pressing chips shows the entries that match any pressed chip, and "Clear filters" shows them all again.
hasEarlierboolean | undefinedfalseWhether there are earlier entries than these; shows a button that calls loadEarlierAction.
loadEarlierAction(() => Promise<void>) | undefined–Loads earlier entries; the button shows it is pending until the promise settles.
emptyReactNode–What it shows when there are no entries; an empty state saying nothing is recorded unless set.
denseboolean | undefinedfalseTighter rows, for a side panel.
labelstring | undefined–The timeline's name, translated by the caller ("Ship's log"); "Timeline" unless set.
motionMotionProp | undefinedtrueHow a new entry arrives and a removed one leaves: by default it fades; a preset or { enter, exit } chooses; false turns it off. An entry already there never moves.
classNamestring | undefined–Classes added to the timeline's own.
testIdstring | undefined–Becomes data-testid on the root.