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
Billing period
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.

Issue layout
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

Date range
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.

Deploy region
Plan
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.

Billing period
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

PropTypeDefaultWhat it does
legendReactNodenoneNames the group. Rendered as the fieldset legend.
hideLegendbooleanfalseHides the legend visually and keeps it for assistive tech.
namestringnoneShared name of the radios, and the field name when the form submits.
options{ value: string; label: ReactNode; ariaLabel?: string; title?: string; disabled?: boolean }[]noneThe segments, in order. Give icon-only options an ariaLabel. title is a short explanation shown on hover and read as the option's description.
defaultValuestringnoneUncontrolled: value of the option checked on first render.
valuestring | nullnoneControlled: the checked option, or null for none. Pair it with onValueChange.
onValueChange(value: string) => voidnoneCalled with the option's value when a segment is checked, by pointer or arrow keys.
...restComponentProps<"fieldset">noneEvery native fieldset attribute, including disabled, form and onChange, which bubbles up from the radios.

Anatomy

root.eq-segmented-controlThe native fieldset.
legend.eq-segmented-control-legend
track.eq-segmented-control-trackThe bordered row that joins the segments. Its ::before is the sliding fill where anchor positioning is supported.
segment.eq-segmented-control-segmentA label wrapping one radio.
input.eq-segmented-control-inputThe native radio, transparent and stretched over its segment.
label.eq-segmented-control-label

Keyboard

TabMoves focus to the checked segment, or the first one when none is checked.
ArrowRightArrowDownChecks the next segment, wrapping to the first.
ArrowLeftArrowUpChecks the previous segment, wrapping to the last.
SpaceChecks 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);
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index