Inputs
Segmented control
Picks one of two to five short options shown side by side, such as a view mode or a billing period.
- Built on
<fieldset><input type="radio">:has():checked- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.3 kB
- Dependencies
- React
Try its options
Loading its options.
Code
import { SegmentedControl } from "@/components/equip/segmented-control";
export default function Demo() {
return (
<SegmentedControl
legend="Billing period"
name="billing"
defaultValue="yearly"
options={[
{ value: "monthly", label: "Monthly" },
{ value: "yearly", label: "Yearly" },
]}
/>
);
}Your versionchange its options in Look, This part
Examples
Icon segments
With the legend hidden and icon labels, each option still needs an ariaLabel to have a name.
Code
import { SegmentedControl } from "@/components/equip/segmented-control";
function Icon({ d }: { d: string }) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d={d} />
</svg>
);
}
export default function Example() {
return (
<SegmentedControl
legend="Issue layout"
hideLegend
name="layout"
defaultValue="board"
options={[
{ value: "list", ariaLabel: "List", label: <Icon d="M5.5 4h8M5.5 8h8M5.5 12h8M2.5 4h0M2.5 8h0M2.5 12h0" /> },
{ value: "board", ariaLabel: "Board", label: <Icon d="M2.5 2.5h3v11h-3zM6.5 2.5h3v7h-3zM10.5 2.5h3v9h-3z" /> },
{ value: "timeline", ariaLabel: "Timeline", label: <Icon d="M2.5 4h6M5.5 8h7M3.5 12h5" /> },
]}
/>
);
}Three options
Code
import { SegmentedControl } from "@/components/equip/segmented-control";
export default function Example() {
return (
<SegmentedControl
legend="Date range"
name="range"
defaultValue="30d"
options={[
{ value: "7d", label: "7 days" },
{ value: "30d", label: "30 days" },
{ value: "90d", label: "90 days" },
]}
/>
);
}Disabled
Disable one option, or the whole fieldset; a disabled fieldset also leaves its value out of the form.
Code
import { SegmentedControl } from "@/components/equip/segmented-control";
export default function Example() {
return (
<div style={{ display: "grid", gap: 16 }}>
<SegmentedControl
legend="Deploy region"
name="region"
defaultValue="eu-west-1"
options={[
{ value: "eu-west-1", label: "eu-west-1" },
{ value: "us-east-1", label: "us-east-1" },
{ value: "ap-south-1", label: "ap-south-1", disabled: true },
]}
/>
<SegmentedControl
legend="Plan"
name="plan"
defaultValue="team"
disabled
options={[
{ value: "team", label: "Team" },
{ value: "business", label: "Business" },
]}
/>
</div>
);
}Controlled
value and onValueChange hold the choice outside, so another control can move it; null checks nothing.
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { SegmentedControl } from "@/components/equip/segmented-control";
const plans = [
{ value: "monthly", label: "Monthly", title: "Billed each month, cancel any time" },
{ value: "yearly", label: "Yearly", title: "Billed once a year, two months free" },
];
export default function Example() {
const [plan, setPlan] = useState<string | null>("yearly");
return (
<div style={{ display: "grid", gap: 8, justifyItems: "start" }}>
<SegmentedControl legend="Billing period" name="billing-controlled" options={plans} value={plan} onValueChange={setPlan} />
<Button variant="ghost" size="s" onClick={() => setPlan(null)}>
Clear choice
</Button>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
legend | ReactNode | none | Names the group. Rendered as the fieldset legend. |
hideLegend | boolean | false | Hides the legend visually and keeps it for assistive tech. |
name | string | none | Shared name of the radios, and the field name when the form submits. |
options | { value: string; label: ReactNode; ariaLabel?: string; title?: string; disabled?: boolean }[] | none | The segments, in order. Give icon-only options an ariaLabel. title is a short explanation shown on hover and read as the option's description. |
defaultValue | string | none | Uncontrolled: value of the option checked on first render. |
value | string | null | none | Controlled: the checked option, or null for none. Pair it with onValueChange. |
onValueChange | (value: string) => void | none | Called with the option's value when a segment is checked, by pointer or arrow keys. |
...rest | ComponentProps<"fieldset"> | none | Every native fieldset attribute, including disabled, form and onChange, which bubbles up from the radios. |
Anatomy
| root | .eq-segmented-control | The native fieldset. |
|---|---|---|
| legend | .eq-segmented-control-legend | |
| track | .eq-segmented-control-track | The bordered row that joins the segments. Its ::before is the sliding fill where anchor positioning is supported. |
| segment | .eq-segmented-control-segment | A label wrapping one radio. |
| input | .eq-segmented-control-input | The native radio, transparent and stretched over its segment. |
| label | .eq-segmented-control-label |
Keyboard
| Tab | Moves focus to the checked segment, or the first one when none is checked. |
|---|---|
| ArrowRightArrowDown | Checks the next segment, wrapping to the first. |
| ArrowLeftArrowUp | Checks the previous segment, wrapping to the last. |
| Space | Checks the focused segment when none is checked yet. |
Notes
- Built on native radios, so it prerenders, submits with a form and, uncontrolled, works before the client script runs.
- Uncontrolled by default. Pass value and onValueChange when the choice also moves from elsewhere; focus stays on the radio as the arrows move it.
- onChange on the fieldset still works too, since change events bubble up from the radios.
- A title shows only on hover, so keep anything the reader must know in the label.
- Use it for a few short options that fit on one line; reach for a select when the list is long.
- Do not use it to switch panels of content. That is a tab list.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/segmented-control.tsx75 lines
"use client";
import type { ComponentProps, ReactNode } from "react";
import "./segmented-control.css";
export type SegmentedControlOption = {
value: string;
/** Visible text or an icon. Give icon-only options an `ariaLabel`. */
label: ReactNode;
/** Accessible name when the label is an icon. */
ariaLabel?: string;
/** A short explanation, shown on hover and read as the option's description. */
title?: string;
disabled?: boolean;
};
export type SegmentedControlProps = Omit<ComponentProps<"fieldset">, "defaultValue" | "name"> & {
/** Names the group. Rendered as the fieldset's legend. */
legend: ReactNode;
/** Hides the legend visually but keeps it for assistive tech. */
hideLegend?: boolean;
/** Shared name of the radios. Also the form field name. */
name: string;
options: SegmentedControlOption[];
/** Value of the option checked on first render. Ignored when value is given. */
defaultValue?: string;
/** Controlled checked option; null checks none. Pair it with onValueChange. */
value?: string | null;
/** Called with the option's value when a segment is checked. */
onValueChange?: (value: string) => void;
};
export function SegmentedControl({
legend,
hideLegend = false,
name,
options,
defaultValue,
value,
onValueChange,
className,
...rest
}: SegmentedControlProps) {
const controlled = value !== undefined;
return (
<fieldset {...rest} className={["eq-segmented-control", className].filter(Boolean).join(" ")}>
<legend className="eq-segmented-control-legend" data-hidden={hideLegend || undefined}>
{legend}
</legend>
<div className="eq-segmented-control-track">
{options.map((option) => (
<label key={option.value} className="eq-segmented-control-segment">
<input
className="eq-segmented-control-input"
type="radio"
name={name}
value={option.value}
{...(controlled
? { checked: option.value === value }
: { defaultChecked: option.value === defaultValue })}
disabled={option.disabled}
aria-label={option.ariaLabel}
// The radio covers its segment, so its title is the one the pointer finds.
title={option.title}
onChange={(event) => {
if (event.currentTarget.checked) onValueChange?.(option.value);
}}
/>
<span className="eq-segmented-control-label">{option.label}</span>
</label>
))}
</div>
</fieldset>
);
}components/equip/segmented-control.css176 lines
@layer equip {
.eq-segmented-control {
min-inline-size: 0;
margin: 0;
padding: 0;
border: 0;
}
.eq-segmented-control-legend {
padding: 0;
margin-bottom: var(--eq-space-1);
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
}
.eq-segmented-control-legend[data-hidden] {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.eq-segmented-control-track {
box-sizing: border-box;
display: inline-flex;
max-width: 100%;
height: var(--eq-h-m);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
}
.eq-segmented-control-segment {
position: relative;
display: inline-flex;
flex: 1 1 auto;
align-items: center;
justify-content: center;
gap: var(--eq-space-1);
min-width: 24px;
padding-inline: var(--eq-space-3);
color: var(--eq-ink-2);
font: var(--eq-weight-strong) var(--eq-text-s) / 1 var(--eq-font);
white-space: nowrap;
cursor: pointer;
user-select: none;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease);
}
.eq-segmented-control-segment + .eq-segmented-control-segment {
border-inline-start: 1px solid var(--eq-line);
}
.eq-segmented-control-segment:first-child {
border-start-start-radius: calc(var(--eq-radius-m) - 1px);
border-end-start-radius: calc(var(--eq-radius-m) - 1px);
}
.eq-segmented-control-segment:last-child {
border-start-end-radius: calc(var(--eq-radius-m) - 1px);
border-end-end-radius: calc(var(--eq-radius-m) - 1px);
}
/* The radio stays in the accessibility tree and takes the clicks; only its drawing goes. */
.eq-segmented-control-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: inherit;
}
.eq-segmented-control-label {
display: inline-flex;
align-items: center;
gap: var(--eq-space-1);
transition: scale var(--eq-dur) var(--eq-ease);
}
/* The segment's content gives under the finger; the fill behind it does not. */
.eq-segmented-control:not(:disabled) .eq-segmented-control-segment:active:not(:has(:disabled)) .eq-segmented-control-label {
scale: var(--eq-press);
}
.eq-segmented-control-label > svg {
flex: none;
width: 16px;
height: 16px;
}
@media (hover: hover) {
.eq-segmented-control-segment:hover:not(:has(:checked, :disabled)) {
background: var(--eq-tint);
color: var(--eq-ink);
}
}
.eq-segmented-control-segment:has(:checked) {
background: var(--eq-solid);
color: var(--eq-solid-ink);
}
/* The divider next to a checked segment disappears into its fill. */
.eq-segmented-control-segment:has(:checked) + .eq-segmented-control-segment {
border-inline-start-color: transparent;
}
.eq-segmented-control-segment:has(:focus-visible) {
z-index: 1;
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-segmented-control:not(:disabled) .eq-segmented-control-segment:has(:disabled),
.eq-segmented-control:disabled .eq-segmented-control-track {
opacity: 0.45;
cursor: not-allowed;
}
.eq-segmented-control:disabled .eq-segmented-control-segment {
cursor: not-allowed;
}
/* Where anchor positioning is there, the fill is one indicator that slides to the checked
segment on the spring instead of jumping between backgrounds. Each control scopes the
anchor name, so several on a page never reach into each other. */
@supports (anchor-name: --a) and (anchor-scope: all) {
.eq-segmented-control-track {
position: relative;
anchor-scope: --eq-segment;
}
.eq-segmented-control-track::before {
content: "";
position: absolute;
position-anchor: --eq-segment;
top: anchor(top);
bottom: anchor(bottom);
left: anchor(left);
right: anchor(right);
border-radius: calc(var(--eq-radius-m) - 1px);
background: var(--eq-solid);
pointer-events: none;
transition:
left var(--eq-dur-move) var(--eq-spring),
right var(--eq-dur-move) var(--eq-spring),
opacity var(--eq-dur) var(--eq-ease);
}
/* Nothing checked yet: no indicator. */
.eq-segmented-control-track:not(:has(:checked))::before {
opacity: 0;
}
/* A checked segment that is disabled on its own fades with it, as its fill did. */
.eq-segmented-control:not(:disabled) .eq-segmented-control-track:has(:checked:disabled)::before {
opacity: 0.45;
}
/* The ink turns over the move duration, so it lands with the fill instead of ahead of it. */
.eq-segmented-control-segment:has(:checked) {
anchor-name: --eq-segment;
background: transparent;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur-move) var(--eq-ease);
}
}
}