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
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
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
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.
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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
| labelrequired | string | – | What is measured, as the caller displays it. |
| valuerequired | string | – | The reading, formatted by the caller. |
| unit | string | undefined | – | The reading's unit, shown after it. |
| trend | ReadingTrend | undefined | – | Which way the reading is moving; no arrow is shown when unset. |
| priority | InstrumentPillPriority | undefined | – | The priority of a reading past a limit; unset, the reading is within its limits. |
| stale | boolean | undefined | false | Whether 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. |
| className | string | undefined | – | Classes added to the pill's own. |
| motion | MotionProp | 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. |
Related
- Reading: the full reading, with its age and source.
- Data integrity: the states a stale or degraded reading shows.