Skip to content
design
Components

Alert card grid

Alert cards in two columns, or one on a narrow screen, with section dividers across both.

On this page
Anchor drag3m 12ssince 02:11 UTC
SV Althea has moved 42 m from the drop position, outside the 35 m swing radius. Wind southwest 24 kn, gusting 31 kn.
Swing radiusvoicetelegramwhatsapp
Bilge water high0m 47ssince 02:13 UTC
The aft bilge pump is running without stopping. Check the stern gland and the cockpit drains.
voicetelegram
Wind rising above the anchoring limit01:40
True wind has risen from 14 to 24 kn over the last hour, and the forecast holds southwest 25 to 30 kn until 06:00. The anchorage at Morgat is open to the southwest; plan a move before first light.
Forecast 30 kn
Acknowledged
House bank at 48%00:52
The house bank will reach 40% at about 05:30 at the present 6.2 A draw.
Acknowledged
Tidal gate opens00:30
The Raz de Sein turns fair at 14:20; leave Morgat by 12:45 to carry it south.
Acknowledged
Shallow water23:18
Depth fell to 3.1 m on the approach; it has been above 5.0 m since 23:26.
Acknowledged
A night's alert list at anchor, grouped by urgency, with one warning across both columns.
Code
import { AlertCard } from "@bosunkit/components/alert-card";
import { Badge } from "@bosunkit/components/badge";
import { Button } from "@bosunkit/components/button";
import { AlertCardGrid } from "@bosunkit/components/alert-card-grid";
import { SectionDivider } from "@bosunkit/components/section-divider";
import { useEffect, useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A full alert list at anchor: immediate alarms side by side, a warning across both columns, the cards being " +
  "monitored, and a cleared one, with live timers and acknowledge buttons.";

/** The anchor drag alarm has been raised this many seconds before the example mounts. */
const DRAG_STARTED_SECONDS: number = 192;
/** The bilge alarm has been raised this many seconds before the example mounts. */
const BILGE_STARTED_SECONDS: number = 47;

function elapsed(seconds: number): string {
  const minutes: number = Math.floor(seconds / 60);
  const rest: number = seconds % 60;
  return `${String(minutes)}m ${String(rest).padStart(2, "0")}s`;
}

export default function Showcase(): ReactElement {
  const [ticks, setTicks]: [number, Dispatch<SetStateAction<number>>] = useState<number>(0);
  const [dragAcknowledged, setDragAcknowledged]: [boolean, Dispatch<SetStateAction<boolean>>] =
    useState<boolean>(false);
  const [bilgeAcknowledged, setBilgeAcknowledged]: [boolean, Dispatch<SetStateAction<boolean>>] =
    useState<boolean>(false);

  useEffect((): (() => void) => {
    const timer: number = window.setInterval((): void => {
      setTicks((current: number): number => current + 1);
    }, 1000);
    return (): void => {
      window.clearInterval(timer);
    };
  }, []);

  return (
    <div style={{ width: "100%" }}>
      <AlertCardGrid>
        <SectionDivider label="Immediate" variant="immediate" />
        <AlertCard
          priority="alarm"
          title="Anchor drag"
          time="02:11"
          timeReference="UTC"
          immediate
          acknowledged={dragAcknowledged}
          message={
            "SV Althea has moved 42 m from the drop position, outside the 35 m swing radius. " +
            "Wind southwest 24 kn, gusting 31 kn."
          }
          badges={<Badge variant="red">Swing radius</Badge>}
          deliveryChannels={[
            { channel: "voice", status: "sent" },
            { channel: "telegram", status: "read" },
            { channel: "whatsapp", status: "failed" },
          ]}
          advisory={[
            "Start the engine and take the strain off the rode.",
            "Wake the skipper.",
            "Re-set the anchor in 6 m on sand, or move to the buoys off Camaret.",
          ]}
          actions={
            <Button
              variant="primary"
              disabled={dragAcknowledged}
              onClick={(): void => {
                setDragAcknowledged(true);
              }}
            >
              Acknowledge
            </Button>
          }
          figures={[
            { label: "From drop", value: "42 m" },
            { label: "Bearing", value: "047°" },
          ]}
          elapsed={elapsed(DRAG_STARTED_SECONDS + ticks)}
        />
        <AlertCard
          priority="alarm"
          title="Bilge water high"
          time="02:13"
          timeReference="UTC"
          immediate
          acknowledged={bilgeAcknowledged}
          message="The aft bilge pump is running without stopping. Check the stern gland and the cockpit drains."
          deliveryChannels={[
            { channel: "voice", status: "sent" },
            { channel: "telegram", status: "pending" },
          ]}
          actions={
            <Button
              variant="primary"
              disabled={bilgeAcknowledged}
              onClick={(): void => {
                setBilgeAcknowledged(true);
              }}
            >
              Acknowledge
            </Button>
          }
          figures={[{ label: "Pump cycles", value: "9 / h" }]}
          elapsed={elapsed(BILGE_STARTED_SECONDS + ticks)}
        />

        <SectionDivider label="Monitoring" variant="monitoring" />
        <AlertCard
          className="alarm--full"
          priority="warning"
          title="Wind rising above the anchoring limit"
          time="01:40"
          acknowledged
          message={
            "True wind has risen from 14 to 24 kn over the last hour, and the forecast holds southwest 25 to 30 kn " +
            "until 06:00. The anchorage at Morgat is open to the southwest; plan a move before first light."
          }
          badges={<Badge variant="amber">Forecast 30 kn</Badge>}
          figures={[
            { label: "True wind", value: "24 kn" },
            { label: "Gusts", value: "31 kn" },
            { label: "Barometer", value: "1004 hPa" },
          ]}
        />
        <AlertCard
          priority="caution"
          title="House bank at 48%"
          time="00:52"
          acknowledged
          message="The house bank will reach 40% at about 05:30 at the present 6.2 A draw."
          figures={[{ label: "Voltage", value: "12.4 V" }]}
        />
        <AlertCard
          priority="none"
          title="Tidal gate opens"
          time="00:30"
          acknowledged
          message="The Raz de Sein turns fair at 14:20; leave Morgat by 12:45 to carry it south."
        />

        <SectionDivider label="Cleared" variant="cleared" />
        <AlertCard
          priority="caution"
          title="Shallow water"
          time="23:18"
          acknowledged
          cleared
          message="Depth fell to 3.1 m on the approach; it has been above 5.0 m since 23:26."
        />
      </AlertCardGrid>
    </div>
  );
}

A card grid lays out the alert cards of a list. It has two columns, and one on a narrow screen or a touch screen. A section divider placed in the grid spans both columns, so the cards below it read as a group. A card that carries alarm--full also takes both columns.

Examples

Cards under a divider

Anchor drag
Shallow water
A divider that spans both columns, then two cards side by side.
Code
import { AlertCardGrid } from "@bosunkit/components/alert-card-grid";
import { SectionDivider } from "@bosunkit/components/section-divider";
import type { ReactElement } from "react";

export const description: string = "A divider that spans both columns, then two cards side by side.";

export default function CardsUnderADivider(): ReactElement {
  return (
    <div style={{ width: "100%" }}>
      <AlertCardGrid>
        <SectionDivider label="Immediate" variant="immediate" />
        <div>Anchor drag</div>
        <div>Shallow water</div>
      </AlertCardGrid>
    </div>
  );
}

On a narrow screen

The grid takes one column on a phone or a tablet, a screen up to the 68.75em breakpoint (1,100 px at 16 px), with 16 px between the cards. The width is the screen's the grid sits in, not the window's: a page, an app's main area or a phone frame declares itself the screen with container: bk-screen / inline-size, and outside one the grid keeps two columns. A touch screen wider than a tablet keeps the two columns: a coarse pointer sizes targets, not the layout.

Anchor drag
41 m from the drop, radius 35 m.
True wind over 25 kn
Gusts to 31 kn from 225°.
A 360 px screen: the same alert list in one column, the cards 16 px apart.
Code
import { AlertCard } from "@bosunkit/components/alert-card";
import { AlertCardGrid } from "@bosunkit/components/alert-card-grid";
import { SectionDivider } from "@bosunkit/components/section-divider";
import type { ReactElement } from "react";

export const description: string = "A 360 px screen: the same alert list in one column, the cards 16 px apart.";

export default function OnANarrowScreen(): ReactElement {
  return (
    <div style={{ container: "bk-screen / inline-size", width: "100%", maxWidth: 360 }}>
      <AlertCardGrid>
        <SectionDivider key="immediate" label="Immediate" variant="immediate" />
        <AlertCard key="drag" priority="alarm" title="Anchor drag" message="41 m from the drop, radius 35 m." />
        <AlertCard key="wind" priority="warning" title="True wind over 25 kn" message="Gusts to 31 kn from 225°." />
      </AlertCardGrid>
    </div>
  );
}

Motion

An alert list moves by default, as the owner decided for alert lists. A new alert's card rises in over at most the medium motion duration, with its text in the page and announced from the first frame; a cleared card fades out with the fast exit and takes no press while it leaves; and the cards slide to a new order. While a card has focus, or the pointer is pressed on one, the new order applies at once, so nothing moves under a finger. Under reduced motion cards only fade, and a new order always applies at once. Cards on screen when the list first renders do not move. Give each card a key. motion={false} turns all of it off.

Wind above reef threshold
22 kn gusts, reef at 20
Shallow water
2.4 m under the keel and shoaling
House battery low
12.1 V, 48 % charge
An alert list that moves: a raised alarm rises in at the top, a cleared alert fades out, and a warning that turns into an alarm slides up to its new place. While a card has focus, the new order applies at once.
Code
import { AlertCard } from "@bosunkit/components/alert-card";
import { Button } from "@bosunkit/components/button";
import { AlertCardGrid } from "@bosunkit/components/alert-card-grid";
import type { Priority } from "@bosunkit/components/priority";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "An alert list that moves: a raised alarm rises in at the top, a cleared alert fades out, and a warning that " +
  "turns into an alarm slides up to its new place. While a card has focus, the new order applies at once.";

interface BoardAlert {
  readonly id: string;
  readonly priority: Priority;
  readonly title: string;
  readonly message: string;
}

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

const START: readonly BoardAlert[] = [
  { id: "wind", priority: "warning", title: "Wind above reef threshold", message: "22 kn gusts, reef at 20" },
  { id: "depth", priority: "warning", title: "Shallow water", message: "2.4 m under the keel and shoaling" },
  { id: "battery", priority: "caution", title: "House battery low", message: "12.1 V, 48 % charge" },
];

function sorted(alerts: readonly BoardAlert[]): BoardAlert[] {
  return [...alerts].sort((a: BoardAlert, b: BoardAlert): number => RANK[a.priority] - RANK[b.priority]);
}

export default function Motion(): ReactElement {
  const [alerts, setAlerts]: [readonly BoardAlert[], Dispatch<SetStateAction<readonly BoardAlert[]>>] =
    useState<readonly BoardAlert[]>(START);
  const has: (id: string) => boolean = (id: string): boolean =>
    alerts.some((alert: BoardAlert): boolean => alert.id === id);
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%" }}>
      <div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-sm)" }}>
        <Button
          size="sm"
          disabled={has("bilge")}
          onClick={(): void => {
            setAlerts((current: readonly BoardAlert[]): BoardAlert[] =>
              sorted([
                ...current,
                { id: "bilge", priority: "alarm", title: "Bilge pump running", message: "6 min without stopping" },
              ]),
            );
          }}
        >
          Raise a bilge alarm
        </Button>
        <Button
          size="sm"
          onClick={(): void => {
            setAlerts((current: readonly BoardAlert[]): BoardAlert[] =>
              sorted(
                current.map(
                  (alert: BoardAlert): BoardAlert =>
                    alert.id === "depth"
                      ? { ...alert, priority: "alarm", message: "1.2 m under the keel and shoaling" }
                      : alert,
                ),
              ),
            );
          }}
        >
          The water shoals
        </Button>
        <Button
          size="sm"
          onClick={(): void => {
            setAlerts((current: readonly BoardAlert[]): BoardAlert[] =>
              current.filter((alert: BoardAlert): boolean => alert.id !== "wind"),
            );
          }}
        >
          The wind eases
        </Button>
        <Button
          size="sm"
          variant="ghost"
          onClick={(): void => {
            setAlerts(START);
          }}
        >
          Start again
        </Button>
      </div>
      <AlertCardGrid>
        {alerts.map(
          (alert: BoardAlert): ReactElement => (
            <AlertCard key={alert.id} priority={alert.priority} title={alert.title} message={alert.message} />
          ),
        )}
      </AlertCardGrid>
    </div>
  );
}

Props

PropTypeDefaultDescription
childrenrequiredReactNode–The cards, and the section dividers between them. Give each a key, so the right one leaves when it clears.
classNamestring | undefined–Classes added to the grid's own.
motionboolean | undefinedtrueWhether the list moves: on by default. A new alert card rises in, a cleared one fades out, and the cards slide to a new order, except while the crew is using the list (a card has focus, or the pointer is pressed on it), when the new order applies at once. Cards on screen when the grid first renders do not move. false turns it off.