Skip to content
design
Components

Sparkline

A small line or row of bars through recent values, with no axes, for a trend seen at a glance.

On this page

Last hour at anchor, 13:00 to 14:00 UTC

SAFE 3 M
3.6
Depth
m
Filtered over the raw soundings; stale 13:20 to 13:25 UTC while the sounder lost the bottom. The forecast interval reaches the safe depth of 3 m from 14:10 UTC.
84
Coolant
°C
Above its normal 75 to 90 °C from 13:25 to 13:35 UTC while motoring to the anchorage.
1007.6
Pressure
hPa
Down 2.2 hPa in the hour from its high at 13:00 UTC.
LOW 12.2 V
12.4
House
V
No reading at 13:30 UTC while the battery monitor restarted; 0.2 V above the low-voltage limit.

Counted per interval, drawn as bars

0.4
Rain
mm/h
A front through from 06:00 to 09:00 UTC, the heaviest hour ringed. The latest hour is at full strength.
1.6
Engine
h today
Two days at anchor with the engine off; no log for Wed 7 Oct.
The last hour at anchor: depth filtered over its raw soundings, stale while the sounder lost the bottom and forecast toward the safe depth; coolant leaving its band, a falling barometer, a battery with a gap, and rain and engine hours as bars. Point at a trend, or tab to it and use the arrow keys.
Code
import { Card } from "@bosunkit/components/card";
import { Reading } from "@bosunkit/components/reading";
import { Sparkline } from "@bosunkit/components/sparkline";
import { Activity, BatteryMedium, CloudRain, Gauge, Thermometer, Timer, Waves, type LucideIcon } from "lucide-react";
import type { CSSProperties, ReactElement, ReactNode } from "react";

export const description: string =
  "The last hour at anchor in Baie de Morgat. Depth is the filtered line over the raw soundings, stale while the " +
  "sounder lost the bottom, with the next 20 minutes forecast and its interval reaching the safe depth. Coolant " +
  "leaves its normal band, the barometer falls from its ringed high, and the battery has a gap where the monitor " +
  "dropped out; beside them, rain per hour and engine hours per day as bars. Point at a trend, or tab to it and use " +
  "the arrow keys; the readout goes on into the forecast.";

/** "13:05 UTC" for the sample `index` steps of `stepMinutes` after `startHour`:00 UTC. */
function timeLabel(startHour: number, stepMinutes: number, index: number): string {
  const minutes: number = startHour * 60 + index * stepMinutes;
  const hour: string = String(Math.floor(minutes / 60) % 24).padStart(2, "0");
  const minute: string = String(minutes % 60).padStart(2, "0");
  return `${hour}:${minute} UTC`;
}

/** Thirteen samples, one every five minutes from 13:00 to 14:00 UTC. */
const HOUR: readonly string[] = Array.from({ length: 13 }, (_: unknown, index: number): string =>
  timeLabel(13, 5, index),
);
/** The forecast's four steps, 14:05 to 14:20 UTC. */
const NEXT_20_MINUTES: readonly string[] = Array.from({ length: 4 }, (_: unknown, index: number): string =>
  timeLabel(14, 5, index + 1),
);
/** Twelve hourly totals, 03:00 to 14:00 UTC. */
const RAIN_HOURS: readonly string[] = Array.from({ length: 12 }, (_: unknown, index: number): string =>
  timeLabel(3, 60, index),
);
const DAYS: readonly string[] = [
  "Mon 28 Sep",
  "Tue 29 Sep",
  "Wed 30 Sep",
  "Thu 1 Oct",
  "Fri 2 Oct",
  "Sat 3 Oct",
  "Sun 4 Oct",
  "Mon 5 Oct",
  "Tue 6 Oct",
  "Wed 7 Oct",
  "Thu 8 Oct",
  "Fri 9 Oct",
  "Sat 10 Oct",
  "Sun 11 Oct",
];

const PANEL: CSSProperties = {
  display: "grid",
  gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
  gap: 16,
  width: "100%",
};
const ROW: CSSProperties = {
  display: "grid",
  gridTemplateColumns: "20px 1fr auto",
  alignItems: "center",
  columnGap: 10,
  rowGap: 2,
  paddingTop: 22,
  paddingBottom: 6,
  borderTop: "1px solid var(--border)",
};
const NOTE: CSSProperties = { gridColumn: "2 / 4", fontSize: 11, color: "var(--text-dim)" };

interface TrendRowProps {
  /** The Lucide icon for the quantity. */
  icon: LucideIcon;
  /** What is measured. */
  label: string;
  /** The latest value, shown beside the trend. */
  value: number;
  /** The value's unit. */
  unit: string;
  /** Decimals the value is shown with. */
  fractionDigits: number;
  /** One line under the trend: what to notice in it. */
  note: string;
  /** The sparkline. */
  children: ReactNode;
}

function TrendRow({ icon: Icon, label, value, unit, fractionDigits, note, children }: TrendRowProps): ReactElement {
  return (
    <div style={ROW}>
      <Icon size={18} aria-hidden="true" color="var(--text-dim)" />
      <span style={{ display: "flex", alignItems: "center", minWidth: 0 }}>{children}</span>
      <Reading value={value} unit={unit} label={label} fractionDigits={fractionDigits} />
      <span style={NOTE}>{note}</span>
    </div>
  );
}

export default function Showcase(): ReactElement {
  return (
    <div style={PANEL}>
      <Card title="Last hour at anchor, 13:00 to 14:00 UTC" icon={<Activity />}>
        <TrendRow
          icon={Waves}
          label="Depth"
          value={3.6}
          fractionDigits={1}
          unit="m"
          note={
            "Filtered over the raw soundings; stale 13:20 to 13:25 UTC while the sounder lost the bottom. " +
            "The forecast interval reaches the safe depth of 3 m from 14:10 UTC."
          }
        >
          <Sparkline
            data={[8.4, 8.1, 7.6, 7.0, 7.0, 7.0, 6.2, 5.4, 4.8, 4.3, 3.9, 3.7, 3.6]}
            rawData={[8.7, 7.8, 7.9, 6.6, null, null, 6.6, 5.0, 5.3, 3.9, 4.2, 3.4, 3.8]}
            filterLabel="Hampel and median of 5"
            quality={[null, null, null, null, "stale", "stale"]}
            qualityNotes={[null, null, null, null, "1 min", "6 min"]}
            forecast={{
              mean: [3.4, 3.2, 3.0, 2.9],
              low: [3.1, 2.8, 2.5, 2.3],
              high: [3.7, 3.6, 3.5, 3.5],
              pointLabels: NEXT_20_MINUTES,
              label: "next 20 minutes, Holt",
            }}
            width={200}
            height={40}
            color="var(--data-categorical-operational-04)"
            yMin={0}
            referenceLines={[{ value: 3, label: "SAFE 3 M" }]}
            markExcursions
            showHigh
            unit="m"
            fractionDigits={1}
            pointLabels={HOUR}
            interactive
            background="var(--surface)"
            label="Depth, last hour"
          />
        </TrendRow>
        <TrendRow
          icon={Thermometer}
          label="Coolant"
          value={84}
          fractionDigits={0}
          unit="°C"
          note="Above its normal 75 to 90 °C from 13:25 to 13:35 UTC while motoring to the anchorage."
        >
          <Sparkline
            data={[82, 84, 85, 86, 88, 91, 93, 92, 89, 86, 85, 84, 84]}
            width={160}
            height={36}
            color="var(--data-categorical-operational-03)"
            curve="monotone"
            normalBand={{ low: 75, high: 90, label: "normal 75 to 90 °C" }}
            markExcursions
            unit="°C"
            pointLabels={HOUR}
            interactive
            background="var(--surface)"
            label="Coolant temperature, last hour"
          />
        </TrendRow>
        <TrendRow
          icon={Gauge}
          label="Pressure"
          value={1007.6}
          fractionDigits={1}
          unit="hPa"
          note="Down 2.2 hPa in the hour from its high at 13:00 UTC."
        >
          <Sparkline
            data={[
              1009.8, 1009.7, 1009.5, 1009.4, 1009.1, 1008.9, 1008.7, 1008.5, 1008.3, 1008.1, 1007.9, 1007.7, 1007.6,
            ]}
            width={160}
            height={36}
            color="var(--data-categorical-operational-01)"
            curve="monotone"
            domainPadding={0.15}
            showLow
            showHigh
            unit="hPa"
            fractionDigits={1}
            pointLabels={HOUR}
            interactive
            background="var(--surface)"
            label="Barometric pressure, last hour"
          />
        </TrendRow>
        <TrendRow
          icon={BatteryMedium}
          label="House"
          value={12.4}
          fractionDigits={1}
          unit="V"
          note="No reading at 13:30 UTC while the battery monitor restarted; 0.2 V above the low-voltage limit."
        >
          <Sparkline
            data={[12.8, 12.7, 12.7, 12.6, 12.6, 12.6, null, 12.5, 12.5, 12.4, 12.4, 12.4, 12.4]}
            width={160}
            height={36}
            color="var(--data-categorical-operational-02)"
            referenceLines={[{ value: 12.2, label: "LOW 12.2 V", priority: "caution" }]}
            domainPadding={0.2}
            unit="V"
            fractionDigits={1}
            pointLabels={HOUR}
            interactive
            background="var(--surface)"
            label="House battery voltage, last hour"
          />
        </TrendRow>
      </Card>
      <Card title="Counted per interval, drawn as bars" icon={<CloudRain />}>
        <TrendRow
          icon={CloudRain}
          label="Rain"
          value={0.4}
          fractionDigits={1}
          unit="mm/h"
          note="A front through from 06:00 to 09:00 UTC, the heaviest hour ringed. The latest hour is at full strength."
        >
          <Sparkline
            data={[0, 0, 0.2, 1.8, 4.6, 6.2, 3.1, 0.8, 0, 0, 0.1, 0.4]}
            plotType="bar"
            width={160}
            height={36}
            color="var(--data-categorical-operational-04)"
            yMin={0}
            showHigh
            unit="mm"
            fractionDigits={1}
            pointLabels={RAIN_HOURS}
            interactive
            background="var(--surface)"
            label="Rain per hour, last 12 hours"
          />
        </TrendRow>
        <TrendRow
          icon={Timer}
          label="Engine"
          value={1.6}
          fractionDigits={1}
          unit="h today"
          note="Two days at anchor with the engine off; no log for Wed 7 Oct."
        >
          <Sparkline
            data={[3.5, 5.2, 0, 0, 6.8, 4.1, 2.2, 1.0, 3.7, null, 7.4, 5.9, 0.6, 1.6]}
            plotType="bar"
            width={160}
            height={36}
            color="var(--data-categorical-operational-01)"
            yMin={0}
            normalBand={{ low: 0, high: 6, label: "service plan up to 6 h a day" }}
            markExcursions
            unit="h"
            fractionDigits={1}
            pointLabels={DAYS}
            interactive
            background="var(--surface)"
            label="Engine hours per day, last 14 days"
          />
        </TrendRow>
      </Card>
    </div>
  );
}

A sparkline is a small drawing of recent values, oldest first, with no axes. It sits beside the reading it belongs to and shows which way that reading has been going: the reading gives the number, the sparkline the last few minutes or hours behind it. It draws a line through the readings, or one bar per reading for an amount per interval. On top of the series it can draw a reference line at a value that matters, a band for the normal range, rings on the lowest and highest reading, and a readout of each point under the pointer or the keyboard. It also carries what bosund knows about each value: how far it can be trusted, the raw samples behind a filtered value, and a forecast ahead of now with its interval.

Compact trends also belong in reports and comparison tables. For an analytical window needing full axes, multiple series and a source table, use Line chart or Bar chart. Operational integrity and forecasts belong to the instrument presentation.

01Small and quietA sparkline is a few dozen pixels high, in a neutral or data color, with no axes and no gridlines. It sits beside a reading, which keeps the number.
02Line for a measurement, bars for an amountDepth, pressure and voltage are measured continuously, so a line joins them. Rain per hour and engine hours per day are counted per interval, so each is a bar.
03Missing stays missingA sample that is missing or not a finite number breaks the line. It is never drawn at zero, and a missing latest sample reads as no reading now.

Choosing what to draw

For compact metric cards, barWidth sets a thin bar in drawing pixels. contextColor supplies the muted bar color, and highlightIndex names a selected interval rather than implying that interval is the latest. The selection never fills a missing observation or moves to another interval. Keep the metric value and comparison period in text beside the sparkline.

Line or bars. Use the line for a quantity that has a value at every moment: depth, speed, pressure, temperature, voltage. Use bars for a quantity that is a total over an interval: rain per hour, engine hours or fuel per day, distance per watch. A line between two daily totals would show values between the days that nobody counted. The latest bar is drawn at full strength and the earlier ones dimmed, as the step that contains now is emphasized on every chart.

Reference line or band. A reference line marks one value the crew acts on, taken from the vessel's own configuration: its safe depth, the battery's low-voltage limit, the reef point. It is thin and solid in its alert priority's color, warning unless said otherwise, and always carries a short label. A band marks the range a reading normally stays within, such as 75 to 90 °C for coolant, in a neutral tint. With markExcursions, readings outside the band are drawn in the warning color, the exceedance tint the data visualization rules allow; the series itself stays in its data color.

Scale. By default the scale runs from the lowest to the highest reading, which makes small changes visible. Fix yMin when the distance from zero is the point, as with depth under the keel, and yMax for a quantity with a natural ceiling. domainPadding keeps the line off the edges. An automatic scale widens to include reference lines and the band.

Marks. Show the latest dot on a live trend, and ring the low and high when the extreme matters, such as the high a barometer has fallen from. Use one or two marks at most: a sparkline with every mark on it is not quiet. Make it interactive where the crew needs to read a past value, and give pointLabels so the readout says when.

Quality. Pass quality with each sample's integrity, in the names bosund gives it: fresh, stale, degraded or invalid, with qualityNotes for a stale sample's age or a degraded sample's reason. A stale or degraded stretch is dashed with a hollow ring on each such sample, so it does not depend on color, and an invalid sample is a gap whatever its value. The readout and the summary say the quality in the words the Reading uses.

Filtered and raw. bosund smooths most readings before anything reads them. Pass the filtered values as data and the raw samples as rawData when the crew should see how noisy the signal is, for a sounder over weed or a wind instrument in gusts. The raw samples are faint dots behind the line and do not widen the scale, so a spike the filter removed does not flatten the line; add domainPadding to keep the ordinary noise in view.

Forecast. Pass forecast with the mean per step and, when the model gives them, the 10 and 90 percent bounds. The forecast is dashed after a now marker, with its interval as a faint band that widens as it reaches ahead. Reference lines and the band apply to it, and with markExcursions a step whose interval reaches across a limit is drawn in the warning color, which is the point of forecasting a depth or a battery.

Accessibility. Given unit or formatValue, the sparkline's accessible name is a summary of what it draws: "Depth, last 30 minutes, 4.6 m now, low 3.4 m, high 6.2 m, SAFE 3 M". Put the quantity and the time window in label. The interactive readout is a status region, so a screen reader hears each value as the keyboard moves; a sparkline that is not interactive has no tab stop and no live region. The highlight never animates.

Examples

Line

Speed over ground for the last ten minutes, one reading a minute, with a dot on the latest: 6.4 kn.
Code
import { Sparkline } from "@bosunkit/components/sparkline";
import type { ReactElement } from "react";

export const description: string =
  "Speed over ground for the last ten minutes, one reading a minute, with a dot on the latest: 6.4 kn.";

export default function Line(): ReactElement {
  return (
    <Sparkline
      data={[5.1, 5.3, 5.2, 5.6, 5.9, 5.8, 6.0, 6.3, 6.1, 6.2, 6.4]}
      unit="kn"
      fractionDigits={1}
      label="Speed over ground, last 10 minutes"
    />
  );
}

Bars

Rain per hour over the last twelve hours as bars from zero. A dry hour is a thin bar on the baseline, and the latest hour is drawn at full strength.
Code
import { Sparkline } from "@bosunkit/components/sparkline";
import type { ReactElement } from "react";

export const description: string =
  "Rain per hour over the last twelve hours as bars from zero. A dry hour is a thin bar on the baseline, and the " +
  "latest hour is drawn at full strength.";

export default function Bars(): ReactElement {
  return (
    <Sparkline
      data={[0, 0, 0.2, 1.8, 4.6, 6.2, 3.1, 0.8, 0, 0, 0.1, 0.4]}
      plotType="bar"
      yMin={0}
      unit="mm"
      fractionDigits={1}
      label="Rain per hour, last 12 hours"
    />
  );
}

Metric cards with a selected interval

Total headcount

1,248people
+4.2%vs last month

Open positions

42roles
+6new this month

New hires / 30d

87hires
+12.5%vs last month
Three illustrative workforce metrics pair a quiet, thin bar sparkline with one selected interval, a large supplied value and a named comparison.
Code
import "@bosunkit/tokens/charts.css";
import { Card } from "@bosunkit/components/card";
import { Sparkline } from "@bosunkit/components/sparkline";
import { ArrowUp } from "lucide-react";
import type { ReactElement } from "react";

export const description: string =
  "Three illustrative workforce metrics pair a quiet, thin bar sparkline with one selected interval, a large supplied value and a named comparison.";
const metrics = [
  {
    label: "Total headcount",
    value: "1,248",
    unit: "people",
    delta: "+4.2%",
    comparison: "vs last month",
    changeColor: "var(--bk-metric-positive)",
    data: [20, 33, 29, 41, 32, 56, 38, 27, 45, 36, 50],
  },
  {
    label: "Open positions",
    value: "42",
    unit: "roles",
    delta: "+6",
    comparison: "new this month",
    changeColor: "var(--bk-metric-neutral-change)",
    data: [20, 33, 29, 41, 32, 56, 38, 27, 45, 36, 50],
  },
  {
    label: "New hires / 30d",
    value: "87",
    unit: "hires",
    delta: "+12.5%",
    comparison: "vs last month",
    changeColor: "var(--bk-metric-positive)",
    data: [20, 33, 29, 41, 32, 56, 38, 27, 45, 36, 50],
  },
] as const;

export default function WorkforceMetrics(): ReactElement {
  return (
    <div
      role="group"
      aria-label="Illustrative workforce metrics"
      style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-xl)", width: "100%" }}
    >
      {metrics.map((metric): ReactElement => (
        <Card
          key={metric.label}
          appearance="metric"
          style={{ flex: "1 1 18em" }}
          title={
            <span
              style={{
                font: "var(--bk-metric-label)",
                letterSpacing: "var(--bk-metric-label-spacing)",
                color: "var(--text-dim)",
                textTransform: "uppercase",
              }}
            >
              {metric.label}
            </span>
          }
          footer={
            <div
              style={{
                width: "100%",
                display: "flex",
                alignItems: "center",
                justifyContent: "space-between",
                gap: "var(--space-sm)",
                flexWrap: "wrap",
              }}
            >
              <span
                aria-hidden="true"
                style={{
                  display: "grid",
                  placeItems: "center",
                  width: "var(--bk-metric-icon-size)",
                  height: "var(--bk-metric-icon-size)",
                  borderRadius: "50%",
                  color: "var(--data-categorical-analytical-03)",
                  background: "var(--bk-metric-icon-surface)",
                }}
              >
                <ArrowUp size={20} aria-hidden="true" />
              </span>
              <span style={{ display: "flex", alignItems: "baseline", gap: "var(--space-sm)", flexWrap: "wrap" }}>
                <strong
                  style={{
                    font: "var(--bk-metric-delta)",
                    fontVariantNumeric: "tabular-nums",
                    color: metric.changeColor,
                  }}
                >
                  {metric.delta}
                </strong>
                <span style={{ font: "var(--bk-type-body-reading)", color: "var(--text-dim)" }}>
                  {metric.comparison}
                </span>
              </span>
            </div>
          }
        >
          <div
            style={{
              display: "flex",
              alignItems: "center",
              justifyContent: "space-between",
              gap: "var(--space-lg)",
              minWidth: 0,
              flexWrap: "wrap",
            }}
          >
            <span style={{ display: "flex", alignItems: "baseline", gap: "var(--space-sm)", whiteSpace: "nowrap" }}>
              <strong style={{ font: "var(--bk-metric-value)", fontVariantNumeric: "tabular-nums" }}>
                {metric.value}
              </strong>
              <span style={{ font: "var(--bk-type-body-reading)", color: "var(--text-dim)" }}>{metric.unit}</span>
            </span>
            <Sparkline
              data={metric.data}
              plotType="bar"
              width={112}
              height={64}
              yMin={0}
              yMax={56}
              barWidth={3}
              color="var(--data-categorical-analytical-03)"
              contextColor="var(--bk-metric-bar-muted)"
              highlightIndex={5}
              label={`${metric.label}, illustrative daily observations; sixth interval selected`}
            />
          </div>
        </Card>
      ))}
    </div>
  );
}

Area

The house battery over the last six hours with a fill under the line, charging under solar from 12.3 V to 13.6 V.
Code
import { Sparkline } from "@bosunkit/components/sparkline";
import type { ReactElement } from "react";

export const description: string =
  "The house battery over the last six hours with a fill under the line, charging under solar from 12.3 V to 13.6 V.";

export default function Area(): ReactElement {
  return (
    <Sparkline
      data={[12.3, 12.4, 12.6, 12.9, 13.2, 13.4, 13.5, 13.6, 13.6, 13.5, 13.6, 13.6, 13.6]}
      area
      unit="V"
      fractionDigits={1}
      label="House battery voltage, last 6 hours"
    />
  );
}

Reference line

SAFE 3 M
Depth under the keel while the vessel swings at anchor, drawn from 0 m, with the vessel's safe depth of 3 m as a labeled line in the warning color.
Code
import { Sparkline } from "@bosunkit/components/sparkline";
import type { ReactElement } from "react";

export const description: string =
  "Depth under the keel while the vessel swings at anchor, drawn from 0 m, with the vessel's safe depth of 3 m as a " +
  "labeled line in the warning color.";

export default function ReferenceLine(): ReactElement {
  return (
    <Sparkline
      data={[6.2, 5.8, 5.1, 4.4, 3.9, 3.6, 3.4, 3.6, 4.1, 4.8, 5.3, 5.0, 4.6]}
      width={160}
      height={40}
      yMin={0}
      area
      referenceLines={[{ value: 3, label: "SAFE 3 M" }]}
      unit="m"
      fractionDigits={1}
      label="Depth, last 30 minutes"
    />
  );
}

Normal band

Engine coolant over the last 40 minutes with its normal 75 to 90 °C band shaded. Three readings above the band, climbing out of the harbor at full power, are drawn in the warning color.
Code
import { Sparkline } from "@bosunkit/components/sparkline";
import type { ReactElement } from "react";

export const description: string =
  "Engine coolant over the last 40 minutes with its normal 75 to 90 °C band shaded. Three readings above the band, " +
  "climbing out of the harbor at full power, are drawn in the warning color.";

export default function NormalBand(): ReactElement {
  return (
    <Sparkline
      data={[68, 74, 79, 83, 86, 88, 91, 93, 92, 89, 87, 86, 85, 85, 84, 84, 85]}
      width={160}
      height={40}
      normalBand={{ low: 75, high: 90, label: "normal 75 to 90 °C" }}
      markExcursions
      unit="°C"
      label="Coolant temperature, last 40 minutes"
    />
  );
}

Low, high and latest

A falling barometer over 24 hours, read hourly: the high of 1021.4 hPa 21 hours ago and the low of 1006.2 hPa now are ringed, and the latest has its dot.
Code
import { Sparkline } from "@bosunkit/components/sparkline";
import type { ReactElement } from "react";

export const description: string =
  "A falling barometer over 24 hours, read hourly: the high of 1021.4 hPa 21 hours ago and the low of " +
  "1006.2 hPa now are ringed, and the latest has its dot.";

export default function Spots(): ReactElement {
  return (
    <Sparkline
      data={[
        1019.8, 1020.6, 1021.1, 1021.4, 1021.2, 1020.9, 1020.3, 1019.6, 1018.9, 1018.0, 1017.2, 1016.1, 1015.3, 1014.2,
        1013.4, 1012.5, 1011.6, 1010.9, 1010.0, 1009.2, 1008.5, 1007.7, 1007.0, 1006.6, 1006.2,
      ]}
      width={160}
      height={40}
      curve="monotone"
      domainPadding={0.1}
      showLow
      showHigh
      unit="hPa"
      fractionDigits={1}
      label="Barometric pressure, last 24 hours"
    />
  );
}

Gaps in the data

Apparent wind with three breaks: the masthead unit sent nothing for two readings, then for one more, and one reading was not a number. The line breaks at each, the reading alone between two breaks is a dot, and nothing is drawn at zero.
Code
import { Sparkline } from "@bosunkit/components/sparkline";
import type { ReactElement } from "react";

export const description: string =
  "Apparent wind with three breaks: the masthead unit sent nothing for two readings, then for one more, and one " +
  "reading was not a number. The line breaks at each, the reading alone between two breaks is a dot, and nothing " +
  "is drawn at zero.";

export default function Gaps(): ReactElement {
  return (
    <Sparkline
      data={[14.2, 15.1, 15.8, null, null, 16.4, null, 17.2, 16.8, 17.5, Number.NaN, 18.1, 18.6]}
      width={160}
      height={40}
      unit="kn"
      fractionDigits={1}
      label="Apparent wind speed, last 12 minutes"
    />
  );
}

Automatic and fixed scale

Automatic: lowest to highest reading
Fixed: yMin 0
The same depths twice. Scaled to the readings, a fall from 5.2 to 4.6 m fills the height; drawn from 0 m, it is a small change in plenty of water.
Code
import { Sparkline } from "@bosunkit/components/sparkline";
import type { CSSProperties, ReactElement } from "react";

export const description: string =
  "The same depths twice. Scaled to the readings, a fall from 5.2 to 4.6 m fills the height; drawn from 0 m, it is " +
  "a small change in plenty of water.";

const FIGURE: CSSProperties = { display: "grid", gap: 4, fontSize: 12, color: "var(--text-dim)" };
const DEPTHS: readonly number[] = [5.2, 5.1, 5.1, 5.0, 4.9, 4.9, 4.8, 4.7, 4.7, 4.6];

export default function Domain(): ReactElement {
  return (
    <div style={{ display: "flex", gap: 32, flexWrap: "wrap" }}>
      <figure style={FIGURE}>
        <Sparkline data={DEPTHS} width={160} height={40} unit="m" fractionDigits={1} label="Depth, scaled to the readings" />
        <figcaption>Automatic: lowest to highest reading</figcaption>
      </figure>
      <figure style={FIGURE}>
        <Sparkline
          data={DEPTHS}
          width={160}
          height={40}
          yMin={0}
          unit="m"
          fractionDigits={1}
          label="Depth, drawn from 0 m"
        />
        <figcaption>Fixed: yMin 0</figcaption>
      </figure>
    </div>
  );
}

Interactive readout

Speed through the water over the last 30 minutes. Point at it, or tab to it and press the arrow keys, Home or End: the readout above names the time and the speed, and a screen reader hears it.
Code
import { Sparkline } from "@bosunkit/components/sparkline";
import type { ReactElement } from "react";

export const description: string =
  "Speed through the water over the last 30 minutes. Point at it, or tab to it and press the arrow keys, Home or " +
  "End: the readout above names the time and the speed, and a screen reader hears it.";

const TIMES: readonly string[] = Array.from({ length: 11 }, (_: unknown, index: number): string => {
  const minutes: number = 14 * 60 + 30 + index * 3;
  return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")} UTC`;
});

export default function Interactive(): ReactElement {
  return (
    <div style={{ paddingTop: 24 }}>
      <Sparkline
        data={[6.8, 6.9, 7.1, 7.0, 6.6, 6.2, 6.4, 6.9, 7.3, 7.4, 7.2]}
        width={200}
        height={40}
        curve="monotone"
        unit="kn"
        fractionDigits={1}
        pointLabels={TIMES}
        interactive
        label="Speed through the water, last 30 minutes"
      />
    </div>
  );
}

Straight and smooth

curve linear
curve monotone
Sea water temperature over a day, straight and smooth. The smooth curve bends only where the readings do, so it never shows a temperature above the highest reading or below the lowest.
Code
import { Sparkline } from "@bosunkit/components/sparkline";
import type { CSSProperties, ReactElement } from "react";

export const description: string =
  "Sea water temperature over a day, straight and smooth. The smooth curve bends only where the readings do, so " +
  "it never shows a temperature above the highest reading or below the lowest.";

const FIGURE: CSSProperties = { display: "grid", gap: 4, fontSize: 12, color: "var(--text-dim)" };
const TEMPERATURES: readonly number[] = [17.8, 17.8, 17.9, 18.4, 19.1, 19.6, 19.6, 19.3, 18.8, 18.4, 18.2, 18.1];

export default function Curve(): ReactElement {
  return (
    <div style={{ display: "flex", gap: 32, flexWrap: "wrap" }}>
      <figure style={FIGURE}>
        <Sparkline data={TEMPERATURES} width={160} height={40} unit="°C" fractionDigits={1} label="Sea temperature" />
        <figcaption>curve linear</figcaption>
      </figure>
      <figure style={FIGURE}>
        <Sparkline
          data={TEMPERATURES}
          width={160}
          height={40}
          curve="monotone"
          unit="°C"
          fractionDigits={1}
          label="Sea temperature"
        />
        <figcaption>curve monotone</figcaption>
      </figure>
    </div>
  );
}

Under a reading

A sparkline under the reading it belongs to, as the dashboard's cards place it: the reading carries the value and its trust, the sparkline only the last few minutes beneath it.
Code
import { Card } from "@bosunkit/components/card";
import { Reading } from "@bosunkit/components/reading";
import { Sparkline } from "@bosunkit/components/sparkline";
import type { ReactElement } from "react";

export const description: string =
  "A sparkline under the reading it belongs to, as the dashboard's cards place it: the reading carries the value " +
  "and its trust, the sparkline only the last few minutes beneath it.";

export default function InAReading(): ReactElement {
  return (
    <Card style={{ width: 180 }}>
      <div style={{ display: "grid", gap: "var(--space-sm)" }}>
        <Reading value={14.6} unit="kn" label="TWS" source="Masthead" fractionDigits={1} trend="rising" />
        <Sparkline
          data={[11.2, 11.8, 12.1, 12.6, 12.4, 13.1, 13.5, 13.4, 14.0, 14.2, 14.6]}
          width={150}
          height={24}
          area
          unit="kn"
          fractionDigits={1}
          label="True wind speed, last 10 minutes"
        />
      </div>
    </Card>
  );
}

Filling its container

width="fill" makes the sparkline as wide as its container. It measures its box and draws at that width, so the line keeps its stroke width and the dots and labels keep their size in a narrow phone column and across a wide panel alike. Use it wherever the sparkline sits in a column of the layout; give a number only where the sparkline sits inline in a line of text or a table cell.

The distance from the anchor over the last 90 seconds, as wide as its panel: drag the panel's corner and the line follows its width, with the stroke, the dot and the swing radius label kept at their size.
Code
import { Sparkline } from "@bosunkit/components/sparkline";
import type { ReactElement } from "react";

export const description: string =
  "The distance from the anchor over the last 90 seconds, as wide as its panel: drag the panel's corner and the " +
  "line follows its width, with the stroke, the dot and the swing radius label kept at their size.";

const DISTANCE: readonly number[] = [22, 24, 27, 25, 29, 31, 30, 33, 32, 34, 33, 31, 29, 30, 28];

export default function FillWidth(): ReactElement {
  return (
    <div
      style={{
        display: "grid",
        gap: "var(--space-xs)",
        width: "100%",
        maxWidth: "36em",
        minWidth: "12em",
        padding: "var(--space-sm)",
        border: "1px solid var(--border)",
        borderRadius: "var(--radius-md)",
        resize: "horizontal",
        overflow: "hidden",
      }}
    >
      <Sparkline
        data={DISTANCE}
        width="fill"
        height={40}
        yMin={0}
        area
        referenceLines={[{ value: 35, label: "SWING 35 M", priority: "alarm" }]}
        unit="m"
        label="Distance from the anchor, last 90 seconds"
      />
    </div>
  );
}

Small multiples

SAFE 3 M
Four quantities side by side over the same 30 minutes, each on its own scale and in its own data color: true wind building, depth shoaling toward the safe depth, pressure falling, and the battery under load.
Code
import { Card } from "@bosunkit/components/card";
import { Reading } from "@bosunkit/components/reading";
import { Sparkline } from "@bosunkit/components/sparkline";
import type { CSSProperties, ReactElement } from "react";

export const description: string =
  "Four quantities side by side over the same 30 minutes, each on its own scale and in its own data color: true " +
  "wind building, depth shoaling toward the safe depth, pressure falling, and the battery under load.";

interface Multiple {
  label: string;
  value: number;
  source: string;
  unit: string;
  data: readonly number[];
  color: string;
  fractionDigits: number;
  yMin?: number;
  referenceLine?: { value: number; label: string };
}

const MULTIPLES: readonly Multiple[] = [
  {
    label: "True wind",
    value: 18.4,
    source: "Masthead",
    unit: "kn",
    data: [12.1, 12.8, 13.5, 13.2, 14.4, 15.1, 15.9, 16.3, 17.2, 17.8, 18.4],
    color: "var(--data-categorical-operational-01)",
    fractionDigits: 1,
  },
  {
    label: "Depth",
    value: 4.1,
    source: "Echosounder",
    unit: "m",
    data: [9.8, 9.1, 8.4, 7.9, 7.1, 6.5, 5.9, 5.3, 4.8, 4.4, 4.1],
    color: "var(--data-categorical-operational-04)",
    fractionDigits: 1,
    yMin: 0,
    referenceLine: { value: 3, label: "SAFE 3 M" },
  },
  {
    label: "Pressure",
    value: 1011.2,
    source: "Barometer",
    unit: "hPa",
    data: [1012.4, 1012.3, 1012.2, 1012.1, 1011.9, 1011.8, 1011.7, 1011.5, 1011.4, 1011.3, 1011.2],
    color: "var(--data-categorical-operational-03)",
    fractionDigits: 1,
  },
  {
    label: "House",
    value: 12.5,
    source: "Shunt",
    unit: "V",
    data: [12.9, 12.8, 12.8, 12.7, 12.6, 12.4, 12.3, 12.4, 12.5, 12.5, 12.5],
    color: "var(--data-categorical-operational-02)",
    fractionDigits: 1,
  },
];

/** A cell's content: the reading over its sparkline. */
const CELL: CSSProperties = { display: "grid", gap: "var(--space-xs)" };

export default function SmallMultiples(): ReactElement {
  return (
    <div
      style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", gap: 8, width: "100%" }}
    >
      {MULTIPLES.map((multiple: Multiple): ReactElement => (
        <Card key={multiple.label}>
          <div style={CELL}>
            <Reading
              value={multiple.value}
              unit={multiple.unit}
              label={multiple.label}
              source={multiple.source}
              fractionDigits={multiple.fractionDigits}
            />
            <Sparkline
              data={multiple.data}
              width={116}
              height={40}
              color={multiple.color}
              yMin={multiple.yMin}
              referenceLines={multiple.referenceLine === undefined ? [] : [multiple.referenceLine]}
              unit={multiple.unit}
              fractionDigits={multiple.fractionDigits}
              background="var(--surface)"
              label={`${multiple.label}, last 30 minutes`}
            />
          </div>
        </Card>
      ))}
    </div>
  );
}

Quality of each sample

Depth while the sounder struggles over weed: two readings degraded by a weak bottom lock, two stale while it sent nothing new, and one ruled invalid, which is a gap. Degraded and stale stretches are dashed with hollow rings, and the readout says why.
Code
import { Sparkline } from "@bosunkit/components/sparkline";
import type { ReactElement } from "react";

export const description: string =
  "Depth while the sounder struggles over weed: two readings degraded by a weak bottom lock, two stale while it " +
  "sent nothing new, and one ruled invalid, which is a gap. Degraded and stale stretches are dashed with hollow " +
  "rings, and the readout says why.";

export default function Quality(): ReactElement {
  return (
    <div style={{ paddingTop: 24 }}>
      <Sparkline
        data={[6.4, 6.3, 6.1, 6.0, 5.8, 5.8, 5.8, 9.9, 5.5, 5.4, 5.3]}
        quality={["fresh", "fresh", "degraded", "degraded", "fresh", "stale", "stale", "invalid", "fresh"]}
        qualityNotes={[null, null, "weak bottom lock", "weak bottom lock", null, "40 s", "1 min 40 s"]}
        width={200}
        height={40}
        yMin={0}
        unit="m"
        fractionDigits={1}
        pointLabels={[
          "10:00 UTC",
          "10:01 UTC",
          "10:02 UTC",
          "10:03 UTC",
          "10:04 UTC",
          "10:05 UTC",
          "10:06 UTC",
          "10:07 UTC",
          "10:08 UTC",
          "10:09 UTC",
          "10:10 UTC",
        ]}
        interactive
        label="Depth, last 10 minutes"
      />
    </div>
  );
}

Filtered over raw

Depth as bosund filters it, a Hampel spike filter then a median of five, drawn over the raw soundings as faint dots. Padding the scale keeps the ordinary noise in view; the spike at 11:04 UTC that the filter removed sits off the scale and is left out. The readout gives the filtered and the raw value.
Code
import { Sparkline } from "@bosunkit/components/sparkline";
import type { ReactElement } from "react";

export const description: string =
  "Depth as bosund filters it, a Hampel spike filter then a median of five, drawn over the raw soundings as faint " +
  "dots. Padding the scale keeps the ordinary noise in view; the spike at 11:04 UTC that the filter removed sits " +
  "off the scale and is left out. The readout gives the filtered and the raw value.";

export default function Filtered(): ReactElement {
  return (
    <div style={{ paddingTop: 24 }}>
      <Sparkline
        data={[12.1, 12.0, 12.0, 11.9, 11.8, 11.8, 11.6, 11.5, 11.5, 11.3, 11.2]}
        rawData={[12.4, 11.7, 12.2, 11.6, 0.6, 12.1, 11.3, 11.8, 11.2, 11.5, 11.0]}
        filterLabel="Hampel and median of 5"
        domainPadding={0.4}
        width={200}
        height={40}
        unit="m"
        fractionDigits={1}
        pointLabels={[
          "11:00 UTC",
          "11:01 UTC",
          "11:02 UTC",
          "11:03 UTC",
          "11:04 UTC",
          "11:05 UTC",
          "11:06 UTC",
          "11:07 UTC",
          "11:08 UTC",
          "11:09 UTC",
          "11:10 UTC",
        ]}
        interactive
        label="Depth, last 10 minutes"
      />
    </div>
  );
}

Forecast with its interval

LOW 12.2 V
The house battery overnight at anchor: the last two hours measured, then the next two forecast as a dashed line after the now marker, with its 10 to 90 percent interval widening as it reaches ahead. The steps whose interval reaches below the 12.2 V low-voltage limit are marked.
Code
import { Sparkline } from "@bosunkit/components/sparkline";
import type { ReactElement } from "react";

export const description: string =
  "The house battery overnight at anchor: the last two hours measured, then the next two forecast as a dashed line " +
  "after the now marker, with its 10 to 90 percent interval widening as it reaches ahead. The steps whose interval " +
  "reaches below the 12.2 V low-voltage limit are marked.";

export default function Forecast(): ReactElement {
  return (
    <div style={{ paddingTop: 24 }}>
      <Sparkline
        data={[12.9, 12.8, 12.8, 12.7, 12.7, 12.6, 12.6, 12.5, 12.5]}
        forecast={{
          mean: [12.5, 12.4, 12.4, 12.3, 12.3, 12.2, 12.2, 12.1],
          low: [12.4, 12.3, 12.2, 12.1, 12.0, 11.9, 11.8, 11.7],
          high: [12.6, 12.5, 12.6, 12.5, 12.6, 12.5, 12.6, 12.5],
          pointLabels: [
            "02:15 UTC",
            "02:30 UTC",
            "02:45 UTC",
            "03:00 UTC",
            "03:15 UTC",
            "03:30 UTC",
            "03:45 UTC",
            "04:00 UTC",
          ],
          label: "next 2 hours, Holt",
        }}
        referenceLines={[{ value: 12.2, label: "LOW 12.2 V", priority: "caution" }]}
        markExcursions
        width={200}
        height={40}
        unit="V"
        fractionDigits={1}
        pointLabels={[
          "00:00 UTC",
          "00:15 UTC",
          "00:30 UTC",
          "00:45 UTC",
          "01:00 UTC",
          "01:15 UTC",
          "01:30 UTC",
          "01:45 UTC",
          "02:00 UTC",
        ]}
        interactive
        label="House battery voltage, last 2 hours"
      />
    </div>
  );
}

Not enough data

A single reading, which cannot make a line: the sparkline draws nothing and a screen reader hears "depth: not enough data".
Code
import { Sparkline } from "@bosunkit/components/sparkline";
import type { ReactElement } from "react";

export const description: string =
  "A single reading, which cannot make a line: the sparkline draws nothing and a screen reader hears \"depth: not enough data\".";

export default function NotEnoughData(): ReactElement {
  return <Sparkline data={[4]} label="depth" />;
}

Props

PropTypeDefaultDescription
datarequiredreadonly (number | null)[]–Series values, oldest first. null is a missing reading, and so is a value that is not a finite number: the line breaks there rather than drawing it, so no gap is drawn as a real reading.
widthnumber | "fill" | undefined120The drawing's width in pixels, 120 by default, or fill to take the whole width of its container. A filling sparkline measures its box and lays the drawing out at that width, so the line, the dots and the labels keep their drawn size at any width; until the first measurement it scales a 120-pixel drawing to the box, with the strokes kept at their width.
heightnumber | undefined32The drawing's height in pixels; 32 by default.
colorstring | undefined"var(--accent)"Any CSS color expression; the accent color by default.
plotTypeSparklinePlotType | undefined"line"line (the default) draws a line through the readings, for a quantity measured continuously (depth, pressure, voltage). bar draws one bar per reading, for an amount per interval (rain per hour, engine hours per day), growing from zero when zero is in the scale; the latest bar is drawn at full strength and the earlier ones dimmed.
barWidthnumber | undefined–Width of each bar in drawing pixels, clamped to its slot; 70% of the slot when unset.
contextColorstring | undefined–Color of unselected bars; unset bars use the series color with reduced opacity.
highlightIndexnumber | undefined–Bar interval to emphasize instead of the latest one; a missing or invalid index has no emphasized bar.
areaboolean | undefinedfalseFill the area under the line with a translucent gradient.
showLatestboolean | undefinedtrueMark the most recent value: a dot on the line, or the one bar at full strength; on by default.
yMinnumber | undefined–The value at the bottom edge; the lowest reading by default. A depth trend passes 0 so the water under the keel is drawn to scale. A reading below it is drawn at the edge.
yMaxnumber | undefined–The value at the top edge; the highest reading by default. A reading above it is drawn at the edge.
domainPaddingnumber | undefined0Room added beyond the lowest and highest reading, as a share of their span (0.1 adds a tenth at each end), so the line does not run along the edges; 0 by default. It applies only to an edge without yMin or yMax.
curveSparklineCurve | undefined"linear"linear (the default) joins the readings with straight segments; monotone with a smooth curve that rises or falls only where the readings do, so it never shows a value beyond the two readings it joins.
referenceLinesreadonly SparklineReferenceLine[] | undefined[]Horizontal lines at values that matter (an alarm limit, the safe depth), thin and solid in their priority's color. An automatic scale widens to include them; a line beyond a fixed yMin or yMax is left out.
normalBandSparklineBand | undefined–The range a reading normally stays within, shaded behind the series in a neutral tint. An automatic scale widens to include it. A band whose low is above its high, or either is not a finite number, is not drawn.
markExcursionsboolean | undefinedfalseDraw each reading outside normalBand in the warning color: a dot on the line, or the whole bar.
qualityreadonly (ReadingIntegrity | null)[] | undefined–Each sample's integrity, aligned with data, as bosund names it: domain.Integrity's fresh, stale, degraded and invalid (packages/bosun/domain/integrity.go:17-45), drawn from the quality monitor's verdicts (instrument/quality/monitor.go:670, domain/quality.go:27). A stale or degraded stretch is drawn dashed with a hollow ring on each such reading, and a stale or degraded bar as a dashed outline; an invalid sample is a gap, whatever its value. A missing entry is fresh.
qualityNotesreadonly (string | null)[] | undefined–The caption for each stale or degraded sample, as the caller displays it: a stale sample's age ("3 min"), or the quality evaluator's reason a degraded one is below full trust ("weak bottom lock"), as domain.IntegrityAssessment's Reason carries it (domain/integrity.go:101-110). Read out with the value.
rawDatareadonly (number | null)[] | undefined–The raw samples behind a filtered series, aligned with data, which then holds the filtered values. bosund's filter pipeline republishes each smoothed reading under the source "filter" beside the raw event it came from (instrument/gate.go:8-14, instrument/filter/wiring.go:81); depth, for one, passes a Hampel spike filter and a median of five (instrument/filter/defaults.go:17). Each raw sample is a faint dot behind the line, so the crew sees the noise the filter removed; it does not widen the scale, and a raw sample beyond it is left out. The readout gives both values.
filterLabelstring | undefined–What the filter is ("Hampel and median of 5"), as the caller displays it; read out with the sparkline's name.
forecastSparklineForecast | undefined–Predicted values after the latest sample. The forecast is drawn dashed after a vertical "now" marker at the latest sample, with its interval as a faint band; an automatic scale widens to include both, and reference lines and the normal band apply to it as to the readings. With markExcursions, a step whose value leaves the band, or whose interval reaches across a reference line from the side the latest reading is on, is drawn in the warning color. The readout names a step as a forecast with its interval ("forecast 3.6 m ± 0.4").
showLowboolean | undefinedfalseRing the lowest reading (the most recent of equal ones); its value is in the accessible summary.
showHighboolean | undefinedfalseRing the highest reading (the most recent of equal ones); its value is in the accessible summary.
backgroundstring | undefined"var(--bg)"The color behind the sparkline, which outlines each reference label so a line passing behind it cannot break the glyphs; the page background by default. A sparkline on a card passes the card's surface color.
strokeWidthnumber | undefined1.5The line's width in pixels; 1.5 by default.
unitstring | undefined–The unit of the values ("m", "hPa"), written after each one in the accessible summary. Given this or formatValue, the accessible name becomes a summary: "Depth, 4.2 m now, low 3.1 m, high 6.8 m".
fractionDigitsnumber | undefined0Fraction digits the values are written with, rounding half to even; 0 by default.
formatValue((value: number) => string) | undefined–Writes one value with its unit, in place of unit and fractionDigits, for a quantity with its own rule (a depth floored so it never overstates the water).
labelstring | undefined–What the trend shows and over what time ("Depth, last 30 minutes"), as the caller displays it; "trend" in the crew's language by default.
interactiveboolean | undefinedfalseLet the crew read each point: under the pointer, or with the arrow keys (Home and End for the first and last, Escape to stop) once the sparkline has focus. A highlight marks the point and a readout above the sparkline writes its value, announced to a screen reader as it changes. Off by default; the highlight never animates.
pointLabelsreadonly string[] | undefined–What each point is ("14:20", "Mon"), as the caller displays it; the readout writes it before the value.
testIdstring | undefined–Becomes data-testid on the root; the highlight is ${testId}--highlight.
classNamestring | undefined–Classes added to the drawing's own.
Do
SAFE 3 M
Draw the series in its data color and show the limit as a labeled line in the warning color.
Don't
Don't color the series by severity. An orange line reads as a warning before anyone has read the limit.
Do
Draw an amount per hour as bars. Each bar is one hour's total.
Don't
Don't join hourly totals with a line. It shows rain between the hours that nobody measured.
  • Instrument history: accepted raw sources, exact filter output and server-side forecast grids.

  • Data visualization: how measured data, thresholds and amounts are drawn.

  • Reading: the reading a sparkline sits beside.

  • Trend chart: a larger chart with axes, for reading values off a scale.