Segmented control
FormsTwo to four exclusive options in one rail, built on real radios, with a thumb that slides without measuring anything.
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
ShowHide
"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
Copy buttonNext 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.