Skip to content
design
Components

Segmented radio group

Two to five options in one row, of which exactly one is chosen, such as the conning screen's rose or chart.

On this page
RoseChartBoth

The compass rose with heading, course over ground and the wind fills the center.

0.5 NM1.5 NM3 NM6 NM12 NM
The conning screen's controls: the center view with icons and labels, the chart's orientation as icons named in tooltips, and the radar range in five steps.
Code
import { SegmentedRadioGroup, type SegmentedRadioOption } from "@bosunkit/components/segmented-radio-group";
import { Compass, Map as MapIcon, Navigation, Ship, SquareSplitHorizontal } from "lucide-react";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "The conning screen's controls: the center view as rose, chart or both, with icons and labels; the chart's " +
  "orientation as icons alone, each named in a tooltip; and the radar range in five steps, held by the control.";

const VIEWS: readonly SegmentedRadioOption[] = [
  { value: "rose", label: "Rose", icon: <Compass size="1.25em" /> },
  { value: "chart", label: "Chart", icon: <MapIcon size="1.25em" /> },
  { value: "both", label: "Both", icon: <SquareSplitHorizontal size="1.25em" /> },
];

const ORIENTATIONS: readonly SegmentedRadioOption[] = [
  { value: "north", label: "North up", icon: <Compass size="1.25em" /> },
  { value: "head", label: "Head up", icon: <Ship size="1.25em" /> },
  { value: "course", label: "Course up", icon: <Navigation size="1.25em" /> },
];

const RANGES: readonly SegmentedRadioOption[] = ["0.5", "1.5", "3", "6", "12"].map((range: string): SegmentedRadioOption => ({
  value: range,
  label: `${range} NM`,
}));

const SHOWN: Readonly<Record<string, string>> = {
  rose: "The compass rose with heading, course over ground and the wind fills the center.",
  chart: "The chart with own vessel, her track and the AIS targets fills the center.",
  both: "The rose and the chart share the center, side by side.",
};

export default function Showcase(): ReactElement {
  const [view, setView]: [string, Dispatch<SetStateAction<string>>] = useState<string>("rose");
  const [orientation, setOrientation]: [string, Dispatch<SetStateAction<string>>] = useState<string>("head");
  return (
    <div style={{ display: "grid", gap: "var(--space-lg)", justifyItems: "start" }}>
      <SegmentedRadioGroup label="Center view" options={VIEWS} value={view} onValueChange={setView} />
      <p style={{ margin: 0, color: "var(--text-dim)" }}>{SHOWN[view]}</p>
      <div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-lg)", alignItems: "center" }}>
        <SegmentedRadioGroup
          label="Chart orientation"
          options={ORIENTATIONS}
          value={orientation}
          onValueChange={setOrientation}
          iconOnly
        />
        <SegmentedRadioGroup label="Radar range" options={RANGES} defaultValue="3" />
      </div>
    </div>
  );
}

A segmented control chooses one of a few views or settings in place: the conning screen's center as rose or chart, the chart's orientation, the wind as true or apparent. The options sit in one row inside a frame, and the chosen one carries a raised mark with an accent line under it. It is a radio group: the group is named by label, each option is a radio named by its own label, and only the chosen option is in the tab order. The arrow keys, in either axis, move to the next or previous option and choose it, wrapping at the ends; a disabled option is skipped.

The mark slides to the next choice over the medium motion duration, so the eye follows it across the row; under reduced motion it moves at once. Every option is at least --target-touch-size tall and wide. With iconOnly the icons stand alone, each option keeps its label as its name, and the label shows in a tooltip on hover and focus.

01One choice, always madeExactly one option is chosen. A control the crew can leave empty is a set of switches or a select, not a segmented control.
02Few options, short namesTwo to five options, each a word or two. More options, or longer names, go in a select.
03A view, not an actionChoosing an option changes what the screen shows or how it reads. An action that changes the vessel is a button that confirms what it did.

Examples

Labels

TrueApparent
Two options as labels: the wind the dial shows, true or apparent. The arrow keys move between them and choose.
Code
import { SegmentedRadioGroup, type SegmentedRadioOption } from "@bosunkit/components/segmented-radio-group";
import type { ReactElement } from "react";

export const description: string =
  "Two options as labels: the wind the dial shows, true or apparent. The arrow keys move between them and choose.";

const WIND: readonly SegmentedRadioOption[] = [
  { value: "true", label: "True" },
  { value: "apparent", label: "Apparent" },
];

export default function Labels(): ReactElement {
  return <SegmentedRadioGroup label="Wind" options={WIND} defaultValue="apparent" />;
}

Icons alone

Icons alone, for a toolbar over the chart: each option is named by its label, which shows in a tooltip.
Code
import { SegmentedRadioGroup, type SegmentedRadioOption } from "@bosunkit/components/segmented-radio-group";
import { Compass, Navigation, Ship } from "lucide-react";
import type { ReactElement } from "react";

export const description: string =
  "Icons alone, for a toolbar over the chart: each option is named by its label, which shows in a tooltip.";

const ORIENTATIONS: readonly SegmentedRadioOption[] = [
  { value: "north", label: "North up", icon: <Compass size="1.25em" /> },
  { value: "head", label: "Head up", icon: <Ship size="1.25em" /> },
  { value: "course", label: "Course up", icon: <Navigation size="1.25em" /> },
];

export default function IconsOnly(): ReactElement {
  return <SegmentedRadioGroup label="Chart orientation" options={ORIENTATIONS} defaultValue="north" iconOnly />;
}

Held by the caller

The control holds its choice itself from defaultValue, or the first option. With value, the caller holds it and the control reports each choice through onValueChange and shows it only once value changes.

25 m35 m50 m75 m

The alarm sounds beyond 35 m from the anchor.

Held by the caller: the screen keeps the anchor watch's alarm radius in its state and shows what it set.
Code
import { SegmentedRadioGroup, type SegmentedRadioOption } from "@bosunkit/components/segmented-radio-group";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "Held by the caller: the screen keeps the anchor watch's alarm radius in its state and shows what it set.";

const RADII: readonly SegmentedRadioOption[] = ["25", "35", "50", "75"].map((radius: string): SegmentedRadioOption => ({
  value: radius,
  label: `${radius} m`,
}));

export default function HeldByCaller(): ReactElement {
  const [radius, setRadius]: [string, Dispatch<SetStateAction<string>>] = useState<string>("35");
  return (
    <div style={{ display: "grid", gap: "var(--space-sm)" }}>
      <SegmentedRadioGroup label="Swing radius" options={RADII} value={radius} onValueChange={setRadius} />
      <p style={{ margin: 0, color: "var(--text-dim)" }}>The alarm sounds beyond {radius} m from the anchor.</p>
    </div>
  );
}

A disabled option

RoseChart

No chart is installed for this area, so the chart is off.

An option that cannot be chosen, skipped by the arrow keys, with the reason written beside the control.
Code
import { SegmentedRadioGroup, type SegmentedRadioOption } from "@bosunkit/components/segmented-radio-group";
import { Compass, Map as MapIcon } from "lucide-react";
import type { ReactElement } from "react";

export const description: string =
  "An option that cannot be chosen, skipped by the arrow keys, with the reason written beside the control.";

const VIEWS: readonly SegmentedRadioOption[] = [
  { value: "rose", label: "Rose", icon: <Compass size="1.25em" /> },
  { value: "chart", label: "Chart", icon: <MapIcon size="1.25em" />, disabled: true },
];

export default function DisabledOption(): ReactElement {
  return (
    <div style={{ display: "grid", gap: "var(--space-sm)" }}>
      <SegmentedRadioGroup label="Center view" options={VIEWS} defaultValue="rose" />
      <p style={{ margin: 0, color: "var(--text-dim)" }}>No chart is installed for this area, so the chart is off.</p>
    </div>
  );
}

Without motion

Under sailUnder powerAt anchor
With motion={false} the chosen option draws its own mark, which moves at once to the next choice.
Code
import { SegmentedRadioGroup, type SegmentedRadioOption } from "@bosunkit/components/segmented-radio-group";
import type { ReactElement } from "react";

export const description: string =
  "With motion={false} the chosen option draws its own mark, which moves at once to the next choice.";

const MODES: readonly SegmentedRadioOption[] = [
  { value: "sail", label: "Under sail" },
  { value: "power", label: "Under power" },
  { value: "anchor", label: "At anchor" },
];

export default function WithoutMotion(): ReactElement {
  return <SegmentedRadioGroup label="Vessel activity" options={MODES} defaultValue="sail" motion={false} />;
}

Props

PropTypeDefaultDescription
namestring | undefined–Submitted field name.
formstring | undefined–ID of an owning form outside the component's ancestors.
requiredboolean | undefined–Requires a choice before native form submission.
readOnlyboolean | undefined–Keeps the value focusable and submitted, but prevents changes.
optionsrequiredreadonly SegmentedRadioOption[]–The choices, 2 to 5, in order.
labelrequiredstring–What the choice is about, read as the group's name ("Center view"); it is not drawn.
valuestring | undefined–The chosen option's value, for a caller that holds the choice.
defaultValuestring | undefined–The option chosen first, when the control holds the choice itself; the first option when unset.
onValueChangeRadioGroupProps["onValueChange"]–Called with an option's value when the crew choose it.
iconOnlyboolean | undefinedfalseDraws each option's icon alone, with its label as its name and in a tooltip. Every option needs an icon.
disabledboolean | undefinedfalseWhether no option can be chosen.
motionboolean | undefinedtrueWhether the selection slides: on by default. The mark behind the chosen option moves to the next one over the medium duration; under reduced motion it moves at once. false draws the mark on the chosen option alone.
classNamestring | undefined–Classes added to the root's own.
testIdstring | undefined–Becomes data-testid on the root; each option is ${testId}--<value>.
refRef<HTMLDivElement> | undefined–The root element.
Do
TrueApparent
Use it for a few views of the same thing, each named in a word.
Don't
StandbyAuto
Don't use it for an action. Engaging the autopilot changes the vessel, so it is a button that confirms what it did.
  • Tabs: views of one subject that each fill a panel below the row.
  • Switch: one setting that is on or off.
  • Select: one choice from a longer list.
  • Conning: the center view switch on the helm screen.