Skip to content
Andrew SmithSolutions
← Component library

Segmented control

Forms

Two to four exclusive options in one rail, built on real radios, with a thumb that slides without measuring anything.

RadiogroupSliding thumbNo measurementCSS only — no dependency

Preview

Controls

Notes

Real radios in a named group, so the arrow keys, the labels and form submission come from the browser rather than from a keydown handler you have to maintain.

The thumb slides on a --seg-index custom property, not on a measured offset. Equal columns mean the distance is always index x 100%, so there is no ResizeObserver, nothing to resynchronise after a font swap, and no first-paint flash with the thumb parked at zero.

The input covers its whole segment, so the real control takes the click and the real focus ring. The label text sits above it with pointer-events: none.

Cap it at four. Past that the labels shrink below a readable size and the honest control is a select.

Source

Show
"use client";

import { useId, useState } from "react";
import { cn } from "@/lib/cn";

/**
 * Real radios in a named group, so the arrow keys, the labels and form
 * submission all come from the browser rather than from a keydown handler.
 *
 * The thumb slides on a --seg-index custom property, NOT on a measured offset.
 * Because the columns are equal, the distance is always index × 100% — so there
 * is no ResizeObserver, nothing to resynchronise after a font swap, and no
 * first-paint flash where the thumb sits at zero before the measurement lands.
 */
const css = `
.seg-input {
  appearance: none;
  -webkit-appearance: none;
  position: absolute;
  inset: 0;
  margin: 0;
  border-radius: 999px;
  cursor: pointer;
}
.seg-thumb {
  position: absolute;
  top: 3px;
  bottom: 3px;
  left: 3px;
  inline-size: calc((100% - 6px) / var(--seg-count));
  border-radius: 999px;
  background: var(--paper-raised);
  border: 1px solid var(--rule-strong);
  translate: calc(var(--seg-index) * 100%) 0;
  transition: translate 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
  pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
  .seg-thumb { transition: none; }
}
`;

const sizes = {
  sm: "py-1 text-[12.5px]",
  md: "py-1.5 text-[13.5px]",
} as const;

export type SegmentedSize = keyof typeof sizes;

export function Segmented({
  options,
  label,
  size = "md",
  fullWidth = false,
  defaultValue,
  onChange,
  className,
}: {
  options: string[];
  /** Names the group for AT; a bare row of radios has nothing to announce. */
  label: string;
  size?: SegmentedSize;
  fullWidth?: boolean;
  defaultValue?: string;
  onChange?: (value: string) => void;
  className?: string;
}) {
  const name = useId();
  const [value, setValue] = useState(defaultValue ?? options[0]);
  const index = Math.max(0, options.indexOf(value));

  return (
    <div
      role="radiogroup"
      aria-label={label}
      className={cn(
        "relative grid rounded-full border border-rule bg-paper-sunken",
        fullWidth ? "w-full" : "w-fit",
        className,
      )}
      style={
        {
          gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`,
          "--seg-count": options.length,
          "--seg-index": index,
        } as React.CSSProperties
      }
    >
      <style href="segmented-thumb" precedence="default">
        {css}
      </style>

      <span aria-hidden="true" className="seg-thumb" />

      {options.map((option) => {
        const selected = option === value;
        return (
          <label
            key={option}
            className={cn(
              "relative z-10 px-4 text-center transition-colors",
              sizes[size],
              selected ? "text-ink" : "text-ink-muted hover:text-ink",
            )}
          >
            {/* The input covers the whole segment, so the real control takes
                the click AND the real focus ring. */}
            <input
              type="radio"
              name={name}
              value={option}
              checked={selected}
              onChange={() => {
                setValue(option);
                onChange?.(option);
              }}
              className="seg-input"
            />
            <span className="pointer-events-none relative whitespace-nowrap">{option}</span>
          </label>
        );
      })}
    </div>
  );
}

const all = ["Month", "Quarter", "Year", "All time"];

export default function Demo({
  count = "3",
  size = "md",
  fullWidth = false,
}: {
  count?: string;
  size?: SegmentedSize;
  fullWidth?: boolean;
}) {
  const options = all.slice(0, Math.min(4, Math.max(2, Number(count) || 2)));

  return (
    <div className="flex w-full max-w-sm justify-center">
      <Segmented key={options.length} options={options} label="Date range" size={size} fullWidth={fullWidth} />
    </div>
  );
}

Next step

Tell me where the business is losing time.

No pitch deck and no discovery-call funnel. Describe the bottleneck and I will tell you plainly whether it is worth building something for, and roughly what that would take.