Badge
A short status label with a colored dot, which becomes a button when it takes a click handler.
On this page
Code
import { Badge, type BadgeVariant } from "@bosunkit/components/badge";
import { Anchor, Gauge, Navigation, Radar, Satellite, Ship, Waves, Wrench, type LucideIcon } from "lucide-react";
import { useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"A vessel status panel: every badge color on the systems it describes, an unacknowledged alarm whose dot " +
"flashes on the alert clock, and an anchor watch badge with an icon that arms and disarms on a tap.";
interface StatusRow {
readonly icon: LucideIcon;
readonly name: string;
readonly detail: string;
readonly badges: readonly { readonly variant: BadgeVariant; readonly text: string }[];
}
const ROWS: readonly StatusRow[] = [
{
icon: Navigation,
name: "Passage",
detail: "Falmouth to A Coruña, leg 2 of 3, 214 nm to go",
badges: [
{ variant: "accent", text: "Underway" },
{ variant: "purple", text: "Autopilot track" },
],
},
{
icon: Gauge,
name: "Engine",
detail: "Yanmar 4JH57, 1,840 rpm, coolant 82 °C",
badges: [
{ variant: "green", text: "Healthy" },
{ variant: "amber", text: "Oil change in 18 h" },
],
},
{
icon: Wrench,
name: "Bilge pump aft",
detail: "Running for 4 min without stopping; check the stern gland",
badges: [{ variant: "red", text: "Alarm" }],
},
{
icon: Waves,
name: "Depth",
detail: "6.2 m under the keel and shoaling, safety depth 5.0 m",
badges: [
{ variant: "yellow", text: "Caution" },
{ variant: "cyan", text: "Swell 1.8 m" },
],
},
{
icon: Radar,
name: "AIS",
detail: "MARIA K, cargo, CPA 1.4 nm in 22 min, passing astern",
badges: [{ variant: "blue", text: "Info" }],
},
{
icon: Satellite,
name: "Backup GPS",
detail: "No position from the masthead antenna since 03:12",
badges: [{ variant: "dim", text: "No fix" }],
},
];
const ROW: CSSProperties = {
display: "grid",
gridTemplateColumns: "20px minmax(0, 1fr) auto",
alignItems: "center",
gap: "var(--space-sm) var(--space-md)",
padding: "var(--space-sm) 0",
borderBottom: "1px solid var(--border)",
};
const BADGES: CSSProperties = { display: "flex", flexWrap: "wrap", justifyContent: "flex-end", gap: 6 };
export default function Showcase(): ReactElement {
const [armed, setArmed]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);
return (
<div style={{ width: "100%", maxWidth: 680 }}>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)", marginBottom: "var(--space-sm)" }}>
<Ship size={16} aria-hidden="true" />
<strong style={{ fontFamily: "var(--font-mono)", fontSize: 13 }}>SV Althea</strong>
<span style={{ color: "var(--text-dim)", fontSize: 12 }}>Hanse 460, first watch, Marta on deck</span>
</div>
{ROWS.map((row: StatusRow): ReactElement => (
<div key={row.name} style={ROW}>
<row.icon size={16} aria-hidden="true" style={{ color: "var(--text-dim)" }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{row.name}</div>
<div style={{ fontSize: 12, color: "var(--text-dim)" }}>{row.detail}</div>
</div>
<div style={BADGES}>
{row.badges.map((badge: { readonly variant: BadgeVariant; readonly text: string }): ReactElement => (
<Badge
key={badge.text}
variant={badge.variant}
unacknowledged={badge.variant === "red"}
>
{badge.text}
</Badge>
))}
</div>
</div>
))}
<div style={{ ...ROW, borderBottom: "none" }}>
<Anchor size={16} aria-hidden="true" style={{ color: "var(--text-dim)" }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Anchor watch</div>
<div style={{ fontSize: 12, color: "var(--text-dim)" }}>
{armed
? "Swing radius 45 m around the drop position; tap the badge to disarm."
: "Not watching the anchor; tap the badge to arm it at the current position."}
</div>
</div>
<div style={BADGES}>
<Badge
variant={armed ? "green" : "dim"}
aria-pressed={armed}
onClick={(): void => {
setArmed((current: boolean): boolean => !current);
}}
>
<Anchor size={11} aria-hidden="true" />
{armed ? "Armed" : "Disarmed"}
</Badge>
</div>
</div>
</div>
);
}
A badge is a short status label with a colored dot, set beside the thing it describes. Its variant names the
color, from the accent by default to the alarm red. A badge reports and does not act: a button
carries the order the crew gives, a badge states what a thing is, so a label never looks tappable and an action never
reads as a reading. With onClick it renders as a button, so it takes focus and a tap; without one it is a span.
Examples
Red
Code
import { Badge } from "@bosunkit/components/badge";
import type { ReactElement } from "react";
export const description: string = "The alarm color, for a state the crew must act on now.";
export default function Red(): ReactElement {
return <Badge variant="red">Critical</Badge>;
}
Colors
A badge draws a dot in the variant's full color, its label in that same color, and a dim tint of it behind: --green
over --green-dim, --red-text over --red-dim. The dot and the word are what a reader uses, so a state reads the
same in Night, in sunlight and to a color-blind eye. The tint is quiet so a row of badges does not shout louder than
the values they describe. The words a severity badge carries belong to
Alarms and severity, which pairs each one with its shape.
Code
import { Badge } from "@bosunkit/components/badge";
import type { ReactElement } from "react";
export const description: string =
"Every badge color, from the accent by default to the alarm red, each with a dot and a word.";
export default function Colors(): ReactElement {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-sm)" }}>
<Badge>Underway</Badge>
<Badge variant="green">Healthy</Badge>
<Badge variant="amber">In progress</Badge>
<Badge variant="yellow">Caution</Badge>
<Badge variant="red">Alarm</Badge>
<Badge variant="blue">Info</Badge>
<Badge variant="cyan">Sea state</Badge>
<Badge variant="purple">Autopilot</Badge>
<Badge variant="dim">No fix</Badge>
</div>
);
}
Interactive
Code
import { Badge } from "@bosunkit/components/badge";
import type { ReactElement } from "react";
export const description: string = "With `onClick` the badge is a button: it takes focus and answers a tap.";
function ignore(): void {
// The example does nothing on a tap.
}
export default function Interactive(): ReactElement {
return (
<Badge variant="green" onClick={ignore}>
Click
</Badge>
);
}
Badges with tooltips
A badge carries its meaning without a tooltip: the dot, the color and the word say what state a thing is in. A tooltip adds detail to that: what the color means, where a reading comes from, how old it is. It never holds the only copy of something the crew needs to act safely, because a touch screen has no hover and a finger on the badge never opens it.
Give a tooltip a title when it explains a badge, so it wraps at a reading width and its text stays legible. The
title names what the detail is about and does not repeat the badge. A badge that is a span is not reachable by
keyboard, so give it tabIndex={0} when it has a tooltip, or make it a button with onClick.
priority matches the tooltip to the badge it explains. alarm, warning and caution draw an edge along the start
of the tooltip in that priority's alert color, the same priority a red, amber or yellow badge shows; every other badge
takes a tooltip with no edge. The edge only matches: the badge and the tooltip's words say the priority, and the color
alone never does.
Code
import { Badge, type BadgeVariant } from "@bosunkit/components/badge";
import { Tooltip, TooltipGroup, type TooltipPriority } from "@bosunkit/components/tooltip";
import type { CSSProperties, ReactElement } from "react";
export const description: string =
"Every badge color with a tooltip that says in one line what the color means. The alarm, warning and caution " +
"tooltips take an edge in their priority's color; the rest stay neutral.";
interface Entry {
readonly variant: BadgeVariant;
readonly text: string;
readonly priority: TooltipPriority;
readonly title: string;
readonly detail: string;
}
const ENTRIES: readonly Entry[] = [
{
variant: "accent",
text: "Underway",
priority: "none",
title: "Vessel state",
detail: "Under way on a passage, not at anchor or in a berth.",
},
{
variant: "green",
text: "Healthy",
priority: "none",
title: "Normal",
detail: "Readings are fresh and within their limits.",
},
{
variant: "amber",
text: "Warning",
priority: "warning",
title: "Attend soon",
detail: "Needs the crew's attention before it becomes an alarm.",
},
{
variant: "yellow",
text: "Caution",
priority: "caution",
title: "Be aware",
detail: "Worth knowing; it needs no acknowledgment.",
},
{
variant: "red",
text: "Alarm",
priority: "alarm",
title: "Act now",
detail: "Danger to the vessel or crew, or a failed system. Acknowledge it and act.",
},
{
variant: "blue",
text: "Info",
priority: "none",
title: "For information",
detail: "A fact to read; nothing to do.",
},
{
variant: "cyan",
text: "Sea state",
priority: "none",
title: "Conditions outside",
detail: "Swell and waves around the vessel, not a fault.",
},
{
variant: "purple",
text: "Autopilot",
priority: "none",
title: "Automation",
detail: "The autopilot is steering; the helm is not.",
},
{
variant: "dim",
text: "No fix",
priority: "none",
title: "No data",
detail: "No position yet; the last known one is not shown as current.",
},
];
const ROW: CSSProperties = { display: "flex", flexWrap: "wrap", gap: "var(--space-sm)" };
export default function TooltipVariants(): ReactElement {
return (
<TooltipGroup>
<div style={ROW}>
{ENTRIES.map((entry: Entry): ReactElement => (
<Tooltip key={entry.text} title={entry.title} content={entry.detail} priority={entry.priority} side="bottom">
<Badge variant={entry.variant} tabIndex={0}>
{entry.text}
</Badge>
</Tooltip>
))}
</div>
</TooltipGroup>
);
}
Alarm detail
An unacknowledged alarm shows the word and the flashing dot on the badge, and a line beside it says what is wrong. The tooltip gives the cause, since when and the sensor it comes from, with the time and its zone. None of it is needed to know there is an alarm or to acknowledge it.
Code
import { Badge } from "@bosunkit/components/badge";
import { Tooltip } from "@bosunkit/components/tooltip";
import type { CSSProperties, ReactElement } from "react";
export const description: string =
"An unacknowledged alarm whose tooltip names the cause, since when and the sensor it comes from. The badge and " +
"the line beside it carry the alarm; the tooltip adds the detail.";
const ROW: CSSProperties = { display: "flex", alignItems: "center", gap: "var(--space-md)", flexWrap: "wrap" };
export default function TooltipAlarm(): ReactElement {
return (
<div style={ROW}>
<Tooltip
title="High bilge level, aft"
content="Water at 38 cm, above the 30 cm limit, since 03:12 UTC (14 min ago). From the aft bilge float switch; the pump is running."
priority="alarm"
side="bottom"
align="start"
>
<Badge variant="red" tabIndex={0} unacknowledged>
Bilge alarm
</Badge>
</Tooltip>
<span style={{ color: "var(--text)" }}>Aft bilge level high, pump running</span>
</div>
);
}
A count and its alerts
A count badge says how many alerts wait, in the color of the highest priority among them. Its tooltip previews them in a line each, so the crew sees what they are before opening the list; the list on the screen is where they read and acknowledge them.
Code
import { Badge } from "@bosunkit/components/badge";
import { Tooltip } from "@bosunkit/components/tooltip";
import type { CSSProperties, ReactElement } from "react";
export const description: string =
"A count of unacknowledged alerts whose tooltip previews the three in a line each. The tooltip wears the color of " +
"the highest priority among them.";
const ROW: CSSProperties = { display: "flex", alignItems: "center", gap: "var(--space-md)", flexWrap: "wrap" };
export default function TooltipCount(): ReactElement {
return (
<div style={ROW}>
<Tooltip
title="Unacknowledged"
content="Alarm: bilge level high, aft. Warning: backup GPS has no fix. Caution: depth 6.2 m, safety depth 5.0 m."
priority="alarm"
side="bottom"
align="start"
>
<Badge variant="red" tabIndex={0}>
3 unacknowledged
</Badge>
</Tooltip>
<span style={{ color: "var(--text-dim)" }}>Open the alert list to read and acknowledge each one.</span>
</div>
);
}
Status, source and age
A connection badge states the state in one or two words. The tooltip names the source and the age of the last reading, the part a reader wants when the state looks wrong: a degraded link takes the caution edge, an offline bus the warning edge.
Code
import { Badge, type BadgeVariant } from "@bosunkit/components/badge";
import { Tooltip, type TooltipPriority } from "@bosunkit/components/tooltip";
import type { CSSProperties, ReactElement } from "react";
export const description: string =
"Connection badges whose tooltips give the source and the age of the last reading, with the time and its zone. " +
"A degraded link takes the caution edge and an offline bus the warning edge.";
interface Link {
readonly variant: BadgeVariant;
readonly text: string;
readonly priority: TooltipPriority;
readonly title: string;
readonly detail: string;
}
const LINKS: readonly Link[] = [
{
variant: "green",
text: "AIS on",
priority: "none",
title: "AIS receiver",
detail: "Class B transponder on the VHF splitter. Last message 2 s ago, at 14:32:07 UTC.",
},
{
variant: "yellow",
text: "Starlink degraded",
priority: "caution",
title: "Starlink",
detail: "Dish on the aft rail, 38 Mbit/s down, 12% obstructed for 10 min. Read at 14:31:50 UTC, 19 s ago.",
},
{
variant: "amber",
text: "NMEA offline",
priority: "warning",
title: "NMEA 2000 bus",
detail: "Gateway at the nav station. No frames since 14:19:42 UTC, 13 min ago.",
},
];
const ROW: CSSProperties = { display: "flex", flexWrap: "wrap", gap: "var(--space-sm)" };
export default function TooltipStatus(): ReactElement {
return (
<div style={ROW}>
{LINKS.map((link: Link): ReactElement => (
<Tooltip
key={link.text}
title={link.title}
content={link.detail}
priority={link.priority}
side="bottom"
align="start"
>
<Badge variant={link.variant} tabIndex={0}>
{link.text}
</Badge>
</Tooltip>
))}
</div>
);
}
A row of badges
A header row holds several badges that each explain themselves. Wrap the row in one TooltipGroup: the first tooltip
waits as usual, and moving along the row opens each next one at once, so reading the row does not stop at every badge.
Code
import { Badge, type BadgeVariant } from "@bosunkit/components/badge";
import { Tooltip, TooltipGroup } from "@bosunkit/components/tooltip";
import { Ship } from "lucide-react";
import type { CSSProperties, ReactElement } from "react";
export const description: string =
"A header row of badges in one TooltipGroup: the first tooltip waits, and moving along the row opens each next one " +
"at once.";
interface Entry {
readonly variant: BadgeVariant;
readonly text: string;
readonly title: string;
readonly detail: string;
}
const ENTRIES: readonly Entry[] = [
{
variant: "accent",
text: "Underway",
title: "Passage",
detail: "Falmouth to A Coruña, leg 2 of 3, 214 nm to go.",
},
{ variant: "green", text: "GPS 3D", title: "Position", detail: "Masthead GPS, 11 satellites, HDOP 0.9." },
{
variant: "blue",
text: "14 AIS targets",
title: "Traffic",
detail: "Within 12 nm; the nearest passes 1.4 nm astern.",
},
{ variant: "purple", text: "Autopilot", title: "Steering", detail: "Holding the track, engaged since 02:40 UTC." },
{
variant: "cyan",
text: "Swell 1.8 m",
title: "Sea state",
detail: "From the forecast for 14:00 UTC, not measured.",
},
];
const HEADER: CSSProperties = {
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: "var(--space-sm) var(--space-md)",
};
export default function TooltipRow(): ReactElement {
return (
<TooltipGroup>
<div style={HEADER}>
<Ship size={16} aria-hidden="true" />
<strong style={{ fontFamily: "var(--font-mono)", fontSize: 13 }}>SV Althea</strong>
{ENTRIES.map((entry: Entry): ReactElement => (
<Tooltip key={entry.text} title={entry.title} content={entry.detail} side="bottom">
<Badge variant={entry.variant} tabIndex={0}>
{entry.text}
</Badge>
</Tooltip>
))}
</div>
</TooltipGroup>
);
}
Keyboard focus
Tab reaches a badge that is a button, and its tooltip opens on focus as it does on hover; Escape closes it without moving focus. The tooltip says what the badge does, and the state stays on the badge and in the text beside it.
Code
import { Badge } from "@bosunkit/components/badge";
import { Tooltip } from "@bosunkit/components/tooltip";
import { Anchor } from "lucide-react";
import { useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"A badge that is a button, so Tab reaches it and its tooltip opens on focus. The state shows on the badge and in " +
"the line beside it; the tooltip says what the badge does.";
const ROW: CSSProperties = { display: "flex", alignItems: "center", gap: "var(--space-md)", flexWrap: "wrap" };
export default function TooltipFocus(): ReactElement {
const [armed, setArmed]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);
return (
<div style={ROW}>
<Tooltip
title="Anchor watch"
content="Raises an alarm when the vessel leaves its 45 m swing circle. Press Enter or Space to change it."
side="bottom"
align="start"
>
<Badge
variant={armed ? "green" : "dim"}
aria-pressed={armed}
onClick={(): void => {
setArmed((current: boolean): boolean => !current);
}}
>
<Anchor size={11} aria-hidden="true" />
{armed ? "Armed" : "Disarmed"}
</Badge>
</Tooltip>
<span style={{ color: "var(--text-dim)" }}>
{armed ? "Watching a 45 m swing circle around the drop position." : "Not watching the anchor."}
</span>
</div>
);
}
Motion
By default a badge pops when its text or count changes, and fades in and out when the caller adds or removes it inside
a MotionPresence; a badge on screen when the page first renders does not move. A red badge is steady unless the
caller marks it unacknowledged: then it flashes its dot on the alert clock, the one loop the system allows, until the
crew acknowledge the alarm and the caller drops the mark; the word and the red stay steady. No other variant flashes.
motion takes the kit's motion prop: true scales the badge in and out, a preset (fade, rise,
scale) or { enter, exit, change } chooses, and false turns that motion off. The flash of an unacknowledged alarm is
not motion the prop chooses: it stays whatever motion says, as it does under reduced motion, where nothing scales or
pops.
Code
import { Badge } from "@bosunkit/components/badge";
import { Button } from "@bosunkit/components/button";
import { MotionPresence } from "@bosunkit/components/motion";
import { useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"The defaults, each on a button: a fix badge fading in and out inside a MotionPresence, a target count that pops, " +
"and an alarm badge marked unacknowledged whose dot flashes until Acknowledge, each beside the same badge with " +
"motion={false}, whose alarm flashes all the same.";
const ROW: CSSProperties = { display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-sm)" };
export default function Motion(): ReactElement {
const [fix, setFix]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);
const [targets, setTargets]: [number, Dispatch<SetStateAction<number>>] = useState<number>(3);
const [acknowledged, setAcknowledged]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)" }}>
<div style={{ ...ROW, minHeight: 32 }}>
<Button
size="sm"
onClick={(): void => {
setFix((current: boolean): boolean => !current);
}}
>
{fix ? "Lose the fix" : "Regain the fix"}
</Button>
<MotionPresence initial={false}>
{fix && (
<Badge key="fix" variant="green">
GPS 3D
</Badge>
)}
</MotionPresence>
{fix && (
<Badge variant="green" motion={false}>
GPS 3D, without motion
</Badge>
)}
</div>
<div style={ROW}>
<Button
size="sm"
onClick={(): void => {
setTargets((current: number): number => current + 1);
}}
>
Another target
</Button>
<Badge variant="blue">{`${String(targets)} AIS targets`}</Badge>
<Badge variant="blue" motion={false}>
{`${String(targets)} AIS targets, without motion`}
</Badge>
</div>
<div style={ROW}>
<Button
size="sm"
onClick={(): void => {
setAcknowledged((current: boolean): boolean => !current);
}}
>
{acknowledged ? "Raise again" : "Acknowledge"}
</Button>
<Badge variant="red" unacknowledged={!acknowledged}>
{acknowledged ? "Bilge alarm, acknowledged" : "Bilge alarm"}
</Badge>
<Badge variant="red" unacknowledged={!acknowledged} motion={false}>
Bilge alarm, motion off
</Badge>
</div>
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | BadgeVariant | undefined | "accent" | The color the badge shows in; accent by default. |
| motion | MotionProp | undefined | – | How the badge moves. By default it pops when its text or count changes and fades in and out when added or removed
inside a MotionPresence. true scales it in and out instead; a preset (fade, rise, scale) or
{ enter, exit, change } chooses; false turns all of it off. An unacknowledged alarm's flash is not motion it
chooses: it stays, whatever this says. |
| unacknowledged | boolean | undefined | false | Whether the badge marks an alarm the crew has not acknowledged yet. A red badge then flashes its dot on the
alert clock until this turns false, whatever motion says; no other variant flashes. Off by default, so a red
badge is steady. |
| ref | Ref<HTMLElement> | undefined | – | The badge's element: a button when onClick is set, a span otherwise. |
Also takes every attribute of HTMLAttributes<HTMLElement>.
Related
- Button: the action a badge sits beside.
- Tooltip: the detail a badge opens on hover and focus, and the priority that matches it.
- Color: the reserved colors, the accent and the dim tints a badge draws on.
- Alarms and severity: the priority colors a badge shares.