Components · Forms
Segmented control
Native radios drawn as one pill track, like a billing-period toggle. One Tab stop, arrows choose.
13% of the 2,062 sites in The UI Index have a tabs (264 sites).
Both previews render the same markup; the HTML tab below is that markup without a framework.
Install
- React
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ segmented.json - Astro
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ astro/ segmented.json
Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS.
import { cn } from '@/lib/utils';
import { segmented as c, type SegmentedOption } from './segmented.variants';
export type { SegmentedOption };
export interface SegmentedProps {
name: string;
legend: string;
options: SegmentedOption[];
defaultValue?: string;
onValueChange?: (value: string) => void;
id?: string;
className?: string;
}
/** Native radios drawn as one pill track: one Tab stop, arrows choose, no JavaScript needed. */
export function Segmented({ name, legend, options, defaultValue, onValueChange, id = name, className }: SegmentedProps) {
return (
<fieldset className={cn(c.root, className)}>
<legend className={c.legend}>{legend}</legend>
<div className={c.track}>
{options.map((o) => (
<div key={o.value} className={c.option}>
<input type="radio" id={`${id}-${o.value}`} name={name} value={o.value} defaultChecked={o.value === defaultValue} disabled={o.disabled} aria-label={o.note ? `${o.label}, ${o.note}` : undefined} className={c.input} onChange={() => onValueChange?.(o.value)} />
<label htmlFor={`${id}-${o.value}`} className={c.label}>
{o.label}
{o.note && <span className={c.note} aria-hidden="true">{o.note}</span>}
</label>
</div>
))}
</div>
</fieldset>
);
}/** A segmented control (the "billing period toggle"): native radios in a fieldset drawn as one pill track, the
* chosen one raised. One Tab stop, arrow keys choose (the browser's radio group), no JavaScript. An optional note
* per option ("save 20%"). */
export const segmented = {
root: 'm-0 inline-flex min-w-0 border-0 p-0',
legend: 'sr-only',
track: 'inline-flex items-center gap-1 rounded-lg bg-muted p-1',
option: 'relative',
input: 'peer absolute inset-0 size-full cursor-pointer appearance-none rounded-md outline-offset-2 disabled:cursor-not-allowed',
// The chosen option keeps its raised white ground under the pointer (a grey fill would read as another option's
// hover); hover darkens its edge, press darkens the ground a step and presses it in.
label:
'pointer-events-none flex h-8 items-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap text-muted-foreground transition-[background-color,color,box-shadow,transform] peer-[:enabled:hover]:bg-input/40 peer-[:enabled:hover]:text-foreground peer-[:enabled:active]:scale-[0.97] peer-[:enabled:active]:bg-pressed peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-[0_1px_2px_rgb(0_0_0/0.08),0_0_0_1px_var(--border)] peer-checked:peer-[:enabled:hover]:shadow-[0_1px_3px_rgb(0_0_0/0.16),0_0_0_1px_color-mix(in_oklab,var(--foreground)_45%,transparent)] peer-checked:peer-[:enabled:hover]:bg-background peer-checked:peer-[:enabled:hover:active]:bg-[color-mix(in_oklab,var(--background)_90%,var(--foreground))] peer-checked:peer-[:enabled:active]:bg-[color-mix(in_oklab,var(--background)_90%,var(--foreground))] peer-disabled:opacity-50',
note: 'rounded-sm bg-foreground px-1 py-px text-[11px] text-background',
};
export type SegmentedOption = { value: string; label: string; note?: string; disabled?: boolean };---
import { cn } from '@/lib/utils';
import { segmented as c, type SegmentedOption } from './segmented.variants';
interface Props {
name: string;
legend: string;
options: SegmentedOption[];
value?: string;
id?: string;
class?: string;
}
const { name, legend, options, value, id = name, class: className } = Astro.props;
---
<fieldset class={cn(c.root, className)}>
<legend class={c.legend}>{legend}</legend>
<div class={c.track}>
{
options.map((o) => (
<div class={c.option}>
<input type="radio" id={`${id}-${o.value}`} name={name} value={o.value} checked={o.value === value} disabled={o.disabled} aria-label={o.note ? `${o.label}, ${o.note}` : undefined} class={c.input} />
<label for={`${id}-${o.value}`} class={c.label}>
{o.label}
{o.note && <span class={c.note} aria-hidden="true">{o.note}</span>}
</label>
</div>
))
}
</div>
</fieldset>/** A segmented control (the "billing period toggle"): native radios in a fieldset drawn as one pill track, the
* chosen one raised. One Tab stop, arrow keys choose (the browser's radio group), no JavaScript. An optional note
* per option ("save 20%"). */
export const segmented = {
root: 'm-0 inline-flex min-w-0 border-0 p-0',
legend: 'sr-only',
track: 'inline-flex items-center gap-1 rounded-lg bg-muted p-1',
option: 'relative',
input: 'peer absolute inset-0 size-full cursor-pointer appearance-none rounded-md outline-offset-2 disabled:cursor-not-allowed',
// The chosen option keeps its raised white ground under the pointer (a grey fill would read as another option's
// hover); hover darkens its edge, press darkens the ground a step and presses it in.
label:
'pointer-events-none flex h-8 items-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap text-muted-foreground transition-[background-color,color,box-shadow,transform] peer-[:enabled:hover]:bg-input/40 peer-[:enabled:hover]:text-foreground peer-[:enabled:active]:scale-[0.97] peer-[:enabled:active]:bg-pressed peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-[0_1px_2px_rgb(0_0_0/0.08),0_0_0_1px_var(--border)] peer-checked:peer-[:enabled:hover]:shadow-[0_1px_3px_rgb(0_0_0/0.16),0_0_0_1px_color-mix(in_oklab,var(--foreground)_45%,transparent)] peer-checked:peer-[:enabled:hover]:bg-background peer-checked:peer-[:enabled:hover:active]:bg-[color-mix(in_oklab,var(--background)_90%,var(--foreground))] peer-checked:peer-[:enabled:active]:bg-[color-mix(in_oklab,var(--background)_90%,var(--foreground))] peer-disabled:opacity-50',
note: 'rounded-sm bg-foreground px-1 py-px text-[11px] text-background',
};
export type SegmentedOption = { value: string; label: string; note?: string; disabled?: boolean };<div class="grid justify-items-center gap-6">
<fieldset class="m-0 inline-flex min-w-0 border-0 p-0">
<legend class="sr-only">Billing period</legend>
<div class="inline-flex items-center gap-1 rounded-lg bg-muted p-1">
<div class="relative">
<input type="radio" id="ex-billing-month" name="ex-billing" value="month" class="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-md outline-offset-2 disabled:cursor-not-allowed">
<label for="ex-billing-month" class="pointer-events-none flex h-8 items-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap text-muted-foreground transition-[background-color,color,box-shadow,transform] peer-[:enabled:hover]:bg-input/40 peer-[:enabled:hover]:text-foreground peer-[:enabled:active]:scale-[0.97] peer-[:enabled:active]:bg-pressed peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-[0_1px_2px_rgb(0_0_0/0.08),0_0_0_1px_var(--border)] peer-checked:peer-[:enabled:hover]:shadow-[0_1px_3px_rgb(0_0_0/0.16),0_0_0_1px_color-mix(in_oklab,var(--foreground)_45%,transparent)] peer-checked:peer-[:enabled:hover]:bg-background peer-checked:peer-[:enabled:hover:active]:bg-[color-mix(in_oklab,var(--background)_90%,var(--foreground))] peer-checked:peer-[:enabled:active]:bg-[color-mix(in_oklab,var(--background)_90%,var(--foreground))] peer-disabled:opacity-50">
Monthly
</label>
</div>
<div class="relative">
<input type="radio" id="ex-billing-year" name="ex-billing" value="year" checked aria-label="Yearly, −20%" class="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-md outline-offset-2 disabled:cursor-not-allowed">
<label for="ex-billing-year" class="pointer-events-none flex h-8 items-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap text-muted-foreground transition-[background-color,color,box-shadow,transform] peer-[:enabled:hover]:bg-input/40 peer-[:enabled:hover]:text-foreground peer-[:enabled:active]:scale-[0.97] peer-[:enabled:active]:bg-pressed peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-[0_1px_2px_rgb(0_0_0/0.08),0_0_0_1px_var(--border)] peer-checked:peer-[:enabled:hover]:shadow-[0_1px_3px_rgb(0_0_0/0.16),0_0_0_1px_color-mix(in_oklab,var(--foreground)_45%,transparent)] peer-checked:peer-[:enabled:hover]:bg-background peer-checked:peer-[:enabled:hover:active]:bg-[color-mix(in_oklab,var(--background)_90%,var(--foreground))] peer-checked:peer-[:enabled:active]:bg-[color-mix(in_oklab,var(--background)_90%,var(--foreground))] peer-disabled:opacity-50">
Yearly
<span class="rounded-sm bg-foreground px-1 py-px text-[11px] text-background" aria-hidden="true">−20%</span>
</label>
</div>
</div>
</fieldset>
<fieldset class="m-0 inline-flex min-w-0 border-0 p-0">
<legend class="sr-only">Range</legend>
<div class="inline-flex items-center gap-1 rounded-lg bg-muted p-1">
<div class="relative">
<input type="radio" id="ex-range-24h" name="ex-range" value="24h" class="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-md outline-offset-2 disabled:cursor-not-allowed">
<label for="ex-range-24h" class="pointer-events-none flex h-8 items-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap text-muted-foreground transition-[background-color,color,box-shadow,transform] peer-[:enabled:hover]:bg-input/40 peer-[:enabled:hover]:text-foreground peer-[:enabled:active]:scale-[0.97] peer-[:enabled:active]:bg-pressed peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-[0_1px_2px_rgb(0_0_0/0.08),0_0_0_1px_var(--border)] peer-checked:peer-[:enabled:hover]:shadow-[0_1px_3px_rgb(0_0_0/0.16),0_0_0_1px_color-mix(in_oklab,var(--foreground)_45%,transparent)] peer-checked:peer-[:enabled:hover]:bg-background peer-checked:peer-[:enabled:hover:active]:bg-[color-mix(in_oklab,var(--background)_90%,var(--foreground))] peer-checked:peer-[:enabled:active]:bg-[color-mix(in_oklab,var(--background)_90%,var(--foreground))] peer-disabled:opacity-50">
24 h
</label>
</div>
<div class="relative">
<input type="radio" id="ex-range-7d" name="ex-range" value="7d" checked class="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-md outline-offset-2 disabled:cursor-not-allowed">
<label for="ex-range-7d" class="pointer-events-none flex h-8 items-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap text-muted-foreground transition-[background-color,color,box-shadow,transform] peer-[:enabled:hover]:bg-input/40 peer-[:enabled:hover]:text-foreground peer-[:enabled:active]:scale-[0.97] peer-[:enabled:active]:bg-pressed peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-[0_1px_2px_rgb(0_0_0/0.08),0_0_0_1px_var(--border)] peer-checked:peer-[:enabled:hover]:shadow-[0_1px_3px_rgb(0_0_0/0.16),0_0_0_1px_color-mix(in_oklab,var(--foreground)_45%,transparent)] peer-checked:peer-[:enabled:hover]:bg-background peer-checked:peer-[:enabled:hover:active]:bg-[color-mix(in_oklab,var(--background)_90%,var(--foreground))] peer-checked:peer-[:enabled:active]:bg-[color-mix(in_oklab,var(--background)_90%,var(--foreground))] peer-disabled:opacity-50">
7 days
</label>
</div>
<div class="relative">
<input type="radio" id="ex-range-30d" name="ex-range" value="30d" class="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-md outline-offset-2 disabled:cursor-not-allowed">
<label for="ex-range-30d" class="pointer-events-none flex h-8 items-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap text-muted-foreground transition-[background-color,color,box-shadow,transform] peer-[:enabled:hover]:bg-input/40 peer-[:enabled:hover]:text-foreground peer-[:enabled:active]:scale-[0.97] peer-[:enabled:active]:bg-pressed peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-[0_1px_2px_rgb(0_0_0/0.08),0_0_0_1px_var(--border)] peer-checked:peer-[:enabled:hover]:shadow-[0_1px_3px_rgb(0_0_0/0.16),0_0_0_1px_color-mix(in_oklab,var(--foreground)_45%,transparent)] peer-checked:peer-[:enabled:hover]:bg-background peer-checked:peer-[:enabled:hover:active]:bg-[color-mix(in_oklab,var(--background)_90%,var(--foreground))] peer-checked:peer-[:enabled:active]:bg-[color-mix(in_oklab,var(--background)_90%,var(--foreground))] peer-disabled:opacity-50">
30 days
</label>
</div>
<div class="relative">
<input type="radio" id="ex-range-1y" name="ex-range" value="1y" disabled class="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-md outline-offset-2 disabled:cursor-not-allowed">
<label for="ex-range-1y" class="pointer-events-none flex h-8 items-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap text-muted-foreground transition-[background-color,color,box-shadow,transform] peer-[:enabled:hover]:bg-input/40 peer-[:enabled:hover]:text-foreground peer-[:enabled:active]:scale-[0.97] peer-[:enabled:active]:bg-pressed peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-[0_1px_2px_rgb(0_0_0/0.08),0_0_0_1px_var(--border)] peer-checked:peer-[:enabled:hover]:shadow-[0_1px_3px_rgb(0_0_0/0.16),0_0_0_1px_color-mix(in_oklab,var(--foreground)_45%,transparent)] peer-checked:peer-[:enabled:hover]:bg-background peer-checked:peer-[:enabled:hover:active]:bg-[color-mix(in_oklab,var(--background)_90%,var(--foreground))] peer-checked:peer-[:enabled:active]:bg-[color-mix(in_oklab,var(--background)_90%,var(--foreground))] peer-disabled:opacity-50">
Year
</label>
</div>
</div>
</fieldset>
</div>