Skip to content
design
Components

Instrument pill

A compact reading with a status dot, its label, its value and unit, and an arrow for the way it is moving.

On this page
Depth12.6 mSOG4.2 knAWS18.5 knHDG164 °TBaro1006 hPaSea temp21.8 °C, staleGPS fix3D
Approach to Puerto de Sóller · safety depth 5 m · reef at 20 kn
A strip of pills on the approach to Puerto de Sóller, on a live feed: depth shoals through warning and critical, wind and speed carry their trends, and the sea temperature is stale.
Code
import { InstrumentPill, type InstrumentPillPriority } from "@bosunkit/components/instrument-pill";
import type { ReadingTrend } from "@bosunkit/components/reading";
import { useEffect, useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A strip of pills on the approach to Puerto de Sóller, on a live feed: depth shoals through the " +
  "warning and critical states, wind and speed carry their trends, and the water temperature has gone stale.";

/** The safety depth and the depth the approach must not go below, in meters. */
const SHALLOW_WARNING_M: number = 5;
const SHALLOW_CRITICAL_M: number = 3;
/** The apparent wind the reef is set for, in knots. */
const REEF_AT_KN: number = 20;

function depthAt(seconds: number): number {
  // Over a bank and back to deep water once every forty seconds.
  return 7.6 + Math.cos((seconds / 40) * 2 * Math.PI) * 5;
}

function windAt(seconds: number): number {
  return 18.5 + Math.sin(seconds / 6) * 3;
}

function speedAt(seconds: number): number {
  return 4.2 + Math.sin(seconds / 11) * 0.6;
}

function trendOf(previous: number, current: number, band: number): ReadingTrend {
  if (current - previous > band) {
    return "rising";
  }
  return previous - current > band ? "falling" : "steady";
}

function depthPriority(depth: number): InstrumentPillPriority | undefined {
  if (depth < SHALLOW_CRITICAL_M) {
    return "alarm";
  }
  return depth < SHALLOW_WARNING_M ? "warning" : undefined;
}

export default function Showcase(): ReactElement {
  const [seconds, setSeconds]: [number, Dispatch<SetStateAction<number>>] = useState<number>(0);
  useEffect((): (() => void) => {
    const timer: ReturnType<typeof setInterval> = setInterval((): void => {
      setSeconds((current: number): number => current + 1);
    }, 1000);
    return (): void => {
      clearInterval(timer);
    };
  }, []);

  const depth: number = depthAt(seconds);
  // A depth is floored, never rounded up, so the pill never shows more water than there is.
  const depthShown: string = (Math.floor(depth * 10) / 10).toFixed(1);
  const wind: number = windAt(seconds);
  const speed: number = speedAt(seconds);

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        gap: "var(--space-md)",
        width: "100%",
        padding: "var(--space-md)",
        border: "1px solid var(--border)",
        borderRadius: "var(--radius-md)",
        background: "var(--bg2)",
      }}
    >
      <div style={{ display: "flex", gap: "var(--space-sm)", flexWrap: "wrap" }}>
        <InstrumentPill
          label="Depth"
          value={depthShown}
          unit="m"
          priority={depthPriority(depth)}
          trend={trendOf(depthAt(seconds - 1), depth, 0.05)}
        />
        <InstrumentPill
          label="SOG"
          value={speed.toFixed(1)}
          unit="kn"
          trend={trendOf(speedAt(seconds - 1), speed, 0.02)}
        />
        <InstrumentPill
          label="AWS"
          value={wind.toFixed(1)}
          unit="kn"
          priority={wind > REEF_AT_KN ? "warning" : undefined}
          trend={trendOf(windAt(seconds - 1), wind, 0.1)}
        />
        <InstrumentPill label="HDG" value="164" unit="°T" trend="steady" />
        <InstrumentPill label="Baro" value="1006" unit="hPa" trend="falling" />
        <InstrumentPill label="Sea temp" value="21.8" unit="°C" stale />
        <InstrumentPill label="GPS fix" value="3D" />
      </div>
      <div
        style={{
          fontFamily: "var(--font-mono)",
          fontSize: 11,
          color: "var(--text-dim)",
        }}
      >
        Approach to Puerto de Sóller · safety depth {SHALLOW_WARNING_M} m · reef at {REEF_AT_KN} kn
      </div>
    </div>
  );
}

An instrument pill is a one-line reading for tight places, such as a strip of values above a chart. The caller formats the value and the unit, so the pill shows the number as the rest of the screen writes it. priority colors the dot and the value of a reading past a limit, amber for warning and red for alarm. stale marks a reading that has stopped updating: it is dimmed, takes no priority's color, and a screen reader hears that it is stale. trend adds an arrow for rising, falling or steady, with a name for a screen reader.

Examples

Depth

Depth12.4 m
A depth of 12.4 meters, with the dot of a reading that can be trusted.
Code
import { InstrumentPill } from "@bosunkit/components/instrument-pill";
import type { ReactElement } from "react";

export const description: string =
  "A depth of 12.4 meters, with the dot of a reading that can be trusted.";

export default function Depth(): ReactElement {
  return <InstrumentPill label="Depth" value="12.4" unit="m" />;
}

Speed rising, warning

SOG6.2 kn
Speed over ground at 6.2 knots in the warning color, with an arrow showing it rising.
Code
import { InstrumentPill } from "@bosunkit/components/instrument-pill";
import type { ReactElement } from "react";

export const description: string =
  "Speed over ground at 6.2 knots in the warning color, with an arrow showing it rising.";

export default function SpeedRisingWarning(): ReactElement {
  return <InstrumentPill label="SOG" value="6.2" unit="kn" priority="warning" trend="rising" />;
}

Motion

By default a pill rises in when the caller adds it inside a MotionPresence and fades out when removed; one there on the first render does not move. motion takes the kit's motion prop: true does the same outside a presence, and false turns it off. A new reading never moves, whatever motion holds: the value jumps to the latest measurement, so the pill never shows a number that was not measured. Under reduced motion the pill only fades in.

Depth12.4 mWater16.8 °C
A water temperature pill that rises in when the sensor is fitted and fades out when it is removed. The depth beside it updates without moving: a new reading jumps to its value.
Code
import { Button } from "@bosunkit/components/button";
import { InstrumentPill } from "@bosunkit/components/instrument-pill";
import { MotionPresence } from "@bosunkit/components/motion";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A water temperature pill that rises in when the sensor is fitted and fades out when it is removed. The depth " +
  "beside it updates without moving: a new reading jumps to its value.";

const DEPTHS: readonly string[] = ["12.4", "11.9", "11.2", "10.8"];

export default function Motion(): ReactElement {
  const [fitted, setFitted]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);
  const [sample, setSample]: [number, Dispatch<SetStateAction<number>>] = useState<number>(0);
  return (
    <div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: "var(--space-sm)" }}>
      <div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-sm)" }}>
        <Button
          size="sm"
          onClick={(): void => {
            setFitted((current: boolean): boolean => !current);
          }}
        >
          {fitted ? "Remove the sensor" : "Fit the sensor"}
        </Button>
        <Button
          size="sm"
          onClick={(): void => {
            setSample((current: number): number => (current + 1) % DEPTHS.length);
          }}
        >
          Next depth reading
        </Button>
      </div>
      <div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-sm)", minHeight: 28 }}>
        <InstrumentPill label="Depth" value={DEPTHS[sample] ?? ""} unit="m" trend="falling" />
        <MotionPresence>
          {fitted && <InstrumentPill key="water" label="Water" value="16.8" unit="°C" />}
        </MotionPresence>
      </div>
    </div>
  );
}

Highlight

A pill highlights its update only when the caller asks, with motion={{ change: "highlight" }}. It tints for the slow motion duration when a new reading arrives, at most once every 2 seconds, so a fast feed does not flicker; the value jumps at once and never rolls. Under reduced motion it never highlights.

Depth8.4 m
A depth pill updating every half second. Asked for a highlight, it tints on an update at most once every 2 seconds, so the fast feed does not flicker, and the value always jumps to the latest reading.
Code
import { InstrumentPill } from "@bosunkit/components/instrument-pill";
import { useEffect, useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A depth pill updating every half second. Asked for a highlight, it tints on an update at most once every 2 " +
  "seconds, so the fast feed does not flicker, and the value always jumps to the latest reading.";

export default function Highlight(): ReactElement {
  const [sample, setSample]: [number, Dispatch<SetStateAction<number>>] = useState<number>(0);
  useEffect((): (() => void) => {
    const timer: ReturnType<typeof setInterval> = setInterval((): void => {
      setSample((current: number): number => current + 1);
    }, 500);
    return (): void => {
      clearInterval(timer);
    };
  }, []);
  const depth: number = 8.4 - 0.4 * Math.abs(Math.sin(sample / 4));
  return (
    <InstrumentPill label="Depth" value={depth.toFixed(1)} unit="m" trend="falling" motion={{ change: "highlight" }} />
  );
}

Props

PropTypeDefaultDescription
labelrequiredstring–What is measured, as the caller displays it.
valuerequiredstring–The reading, formatted by the caller.
unitstring | undefined–The reading's unit, shown after it.
trendReadingTrend | undefined–Which way the reading is moving; no arrow is shown when unset.
priorityInstrumentPillPriority | undefined–The priority of a reading past a limit; unset, the reading is within its limits.
staleboolean | undefinedfalseWhether the reading has stopped updating: it is dimmed and a screen reader hears "stale", and it draws no priority's color, since it is not known to be past the limit now.
classNamestring | undefined–Classes added to the pill's own.
motionMotionProp | undefined–How the pill moves. By default, inside a MotionPresence where the caller adds and removes it, it rises in and fades out; one there on the first render does not move. true does the same outside a presence too; a preset (fade, rise, scale) or { enter, exit, change } chooses; false turns it off. A new reading jumps to its value and never rolls; change: "highlight" tints the pill briefly when it arrives, at most once every 2 s, and never under reduced motion. A pop, a fade on change and attention are ignored.
  • Reading: the full reading, with its age and source.
  • Data integrity: the states a stale or degraded reading shows.