Segmented Control

A radio-group toggle with a springy sliding thumb, badges and arrow-key selection.

  • Buttons
  • SaaS
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/segmented-control.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/buttons/segmented-control.tsx
1/**
2 * SegmentedControl — a single-choice control with a thumb that slides
3 * between options on a spring (billing period, view mode, density…).
4 *
5 * Props: options[] ({ value, label, badge?, icon?, disabled? }), value /
6 * defaultValue / onChange, size ("sm" | "md" | "lg"), fullWidth,
7 * aria-label (required unless aria-labelledby), theme.
8 * CSS vars: --fd-seg-accent (thumb tint), --fd-seg-radius.
9 *
10 * Browser support: all evergreen browsers; the thumb is positioned from
11 * measured offsets (ResizeObserver) so any label width works.
12 * Accessibility: role="radiogroup" with role="radio" options and a roving
13 * tabindex. ArrowLeft/Right/Up/Down move AND select (like native radios),
14 * Home/End jump. Reduced motion: the thumb jumps without overshoot.
15 */
16import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
17import type { KeyboardEvent, ReactNode } from "react";
18
19export interface SegmentOption {
20 value: string;
21 label: ReactNode;
22 badge?: ReactNode;
23 icon?: ReactNode;
24 disabled?: boolean;
25}
26
27export interface SegmentedControlProps {
28 options: SegmentOption[];
29 value?: string;
30 defaultValue?: string;
31 onChange?: (value: string) => void;
32 size?: "sm" | "md" | "lg";
33 fullWidth?: boolean;
34 theme?: "light" | "dark";
35 className?: string;
36 "aria-label"?: string;
37 "aria-labelledby"?: string;
38}
39
40export function SegmentedControl({
41 options,
42 value,
43 defaultValue,
44 onChange,
45 size = "md",
46 fullWidth,
47 theme,
48 className,
49 ...aria
50}: SegmentedControlProps) {
51 const [inner, setInner] = useState(defaultValue ?? options[0]?.value);
52 const current = value ?? inner;
53 const refs = useRef<Record<string, HTMLButtonElement | null>>({});
54 const track = useRef<HTMLDivElement>(null);
55 const [rect, setRect] = useState<{ x: number; w: number } | null>(null);
56 const [ready, setReady] = useState(false);
57
58 const measure = useCallback(() => {
59 const el = refs.current[current];
60 if (el) setRect({ x: el.offsetLeft, w: el.offsetWidth });
61 }, [current]);
62
63 useLayoutEffect(measure, [measure, options.length]);
64 useEffect(() => {
65 const t = window.setTimeout(() => setReady(true), 30);
66 const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null;
67 if (ro && track.current) ro.observe(track.current);
68 Object.values(refs.current).forEach((n) => n && ro?.observe(n));
69 return () => {
70 window.clearTimeout(t);
71 ro?.disconnect();
72 };
73 }, [measure]);
74
75 const select = (v: string) => {
76 if (value === undefined) setInner(v);
77 onChange?.(v);
78 };
79
80 const onKey = (e: KeyboardEvent) => {
81 const enabled = options.filter((o) => !o.disabled);
82 const i = enabled.findIndex((o) => o.value === current);
83 let n = -1;
84 if (e.key === "ArrowRight" || e.key === "ArrowDown") n = (i + 1) % enabled.length;
85 else if (e.key === "ArrowLeft" || e.key === "ArrowUp") n = (i - 1 + enabled.length) % enabled.length;
86 else if (e.key === "Home") n = 0;
87 else if (e.key === "End") n = enabled.length - 1;
88 if (n < 0) return;
89 e.preventDefault();
90 const next = enabled[n];
91 select(next.value);
92 refs.current[next.value]?.focus();
93 };
94
95 return (
96 <div className={`fd-seg${className ? " " + className : ""}`} data-size={size} data-full={fullWidth || undefined} data-ready={ready || undefined} data-fd-theme={theme}>
97 <style>{css}</style>
98 <div ref={track} className="fd-seg-track" role="radiogroup" onKeyDown={onKey} {...aria}>
99 {rect && <span className="fd-seg-thumb" aria-hidden="true" style={{ transform: `translateX(${rect.x}px)`, width: rect.w }} />}
100 {options.map((o) => {
101 const on = o.value === current;
102 return (
103 <button
104 key={o.value}
105 ref={(n) => {
106 refs.current[o.value] = n;
107 }}
108 type="button"
109 role="radio"
110 aria-checked={on}
111 tabIndex={on ? 0 : -1}
112 disabled={o.disabled}
113 className="fd-seg-opt"
114 onClick={() => select(o.value)}
115 >
116 {o.icon && <span className="fd-seg-ic">{o.icon}</span>}
117 <span className="fd-seg-label">{o.label}</span>
118 {o.badge && <span className="fd-seg-badge">{o.badge}</span>}
119 </button>
120 );
121 })}
122 </div>
123 </div>
124 );
125}
126
127const css = `
128:where(.fd-seg,.fd-seg *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none}
129:where(.fd-seg,.fd-seg *)::before,:where(.fd-seg,.fd-seg *)::after{box-sizing:border-box}
130:where(.fd-seg svg){display:block;flex-shrink:0}
131:where(.fd-seg button){appearance:none;-webkit-appearance:none;background:none;border:0;border-radius:0}
132.fd-seg{
133 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);--fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;
134 --g-track:rgba(11,11,15,.06);--g-thumb:#fff;--g-fg:#0B0B0F;--g-mut:#6B6B76;--g-ring:rgba(0,122,255,.7);--g-badge-bg:rgba(0,122,255,.12);--g-badge-fg:#0058C7;
135 --g-thumb-sh:0 0 0 .5px rgba(11,11,15,.06),0 1px 2px rgba(11,11,15,.12),0 3px 8px -2px rgba(11,11,15,.12);
136 display:inline-flex;vertical-align:middle;max-width:100%;
137 font:500 14px/1 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;letter-spacing:-.005em;color:var(--g-fg);
138}
139[data-fd-theme="dark"] .fd-seg,.fd-seg[data-fd-theme="dark"]{--g-track:rgba(255,255,255,.07);--g-thumb:#34343D;--g-fg:#F5F5F7;--g-mut:#9A9AA6;--g-ring:rgba(10,132,255,.85);--g-badge-bg:rgba(48,209,88,.18);--g-badge-fg:#5BE382;
140 --g-thumb-sh:0 1px 0 rgba(255,255,255,.08) inset,0 0 0 .5px rgba(0,0,0,.4),0 2px 6px rgba(0,0,0,.4)}
141.fd-seg[data-full]{display:flex;width:100%}
142.fd-seg[data-size="sm"]{--g-h:28px;--g-px:12px;--g-fs:13px}
143.fd-seg[data-size="md"]{--g-h:36px;--g-px:16px;--g-fs:14px}
144.fd-seg[data-size="lg"]{--g-h:44px;--g-px:22px;--g-fs:15px}
145.fd-seg-track{position:relative;display:inline-flex;align-items:stretch;padding:3px;border-radius:9999px;background:var(--g-track);max-width:100%;overflow-x:auto;scrollbar-width:none}
146.fd-seg-track::-webkit-scrollbar{display:none}
147.fd-seg[data-full] .fd-seg-track{display:flex;width:100%}
148.fd-seg[data-full] .fd-seg-opt{flex:1}
149.fd-seg-thumb{position:absolute;z-index:0;left:0;top:3px;bottom:3px;border-radius:9999px;background:var(--g-thumb);box-shadow:var(--g-thumb-sh);pointer-events:none;will-change:transform,width}
150.fd-seg[data-ready] .fd-seg-thumb{transition:transform var(--fd-dur-3) var(--fd-spring),width var(--fd-dur-3) var(--fd-spring)}
151.fd-seg-opt{position:relative;z-index:1;display:inline-flex;align-items:center;justify-content:center;gap:8px;height:calc(var(--g-h) - 6px);padding:0 var(--g-px);border-radius:9999px;
152 appearance:none;-webkit-appearance:none;border:0;outline:0;cursor:pointer;user-select:none;-webkit-tap-highlight-color:transparent;background:transparent;white-space:nowrap;
153 color:var(--g-mut);font-family:inherit;font-size:var(--g-fs);font-weight:500;line-height:1;letter-spacing:inherit;
154 transform:scale(1);transition:color var(--fd-dur-2) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-spring)}
155.fd-seg-opt[aria-checked="true"]{color:var(--g-fg)}
156@media (hover:hover){.fd-seg-opt[aria-checked="false"]:hover:not(:disabled){color:var(--g-fg)}}
157.fd-seg-opt:active{transform:scale(.96);transition:transform var(--fd-dur-1) var(--fd-ease-out)}
158.fd-seg-opt:focus-visible{outline:2px solid var(--g-ring);outline-offset:1px}
159.fd-seg-opt:disabled{opacity:.4;cursor:not-allowed}
160.fd-seg-ic{display:grid;place-items:center;width:16px;height:16px}.fd-seg-ic svg{width:100%;height:100%}
161.fd-seg-label{display:block}
162.fd-seg-badge{display:inline-flex;align-items:center;height:20px;padding:0 7px;border-radius:9999px;background:var(--g-badge-bg);color:var(--g-badge-fg);font-size:11px;font-weight:600;letter-spacing:0}
163@media (prefers-reduced-motion:reduce){.fd-seg[data-ready] .fd-seg-thumb{transition:transform 1ms linear,width 1ms linear}.fd-seg-opt,.fd-seg-opt:active{transition:none;transform:none}}
164@media (prefers-contrast:more){.fd-seg-track{box-shadow:0 0 0 1px currentColor}.fd-seg-thumb{box-shadow:0 0 0 1px currentColor}}
165`;
166
167/* ───────────────────────────── demo ───────────────────────────── */
168
169function useTween(target: number, ms = 420) {
170 const [v, setV] = useState(target);
171 const from = useRef(target);
172 useEffect(() => {
173 if (!window.matchMedia || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
174 setV(target);
175 from.current = target;
176 return;
177 }
178 const a = from.current;
179 const t0 = performance.now();
180 let raf = 0;
181 const step = (t: number) => {
182 const k = Math.min(1, (t - t0) / ms);
183 const e = 1 - Math.pow(1 - k, 4);
184 const nv = a + (target - a) * e;
185 from.current = nv;
186 setV(nv);
187 if (k < 1) raf = requestAnimationFrame(step);
188 };
189 raf = requestAnimationFrame(step);
190 return () => cancelAnimationFrame(raf);
191 }, [target, ms]);
192 return v;
193}
194
195const PLANS = [
196 { id: "starter", name: "Starter", blurb: "For individuals tracking their first runs.", monthly: 0, yearly: 0, feats: ["3 active projects", "7-day history", "Community support"] },
197 { id: "team", name: "Team", blurb: "For product teams shipping weekly.", monthly: 24, yearly: 19, popular: true, feats: ["Unlimited projects", "1-year history", "SSO and audit log", "Priority support"] },
198 { id: "business", name: "Business", blurb: "For orgs with compliance needs.", monthly: 49, yearly: 39, feats: ["Everything in Team", "SAML + SCIM", "Custom data retention", "Dedicated success manager"] },
199];
200
201function Price({ plan, yearly, seats }: { plan: (typeof PLANS)[number]; yearly: boolean; seats: number }) {
202 const per = useTween(yearly ? plan.yearly : plan.monthly);
203 const total = per * seats;
204 return (
205 <div className="fd-seg-demo-price">
206 <span className="fd-seg-demo-amt">${Math.round(per)}</span>
207 <span className="fd-seg-demo-per">/ seat / mo</span>
208 <p>{plan.monthly === 0 ? "Free forever" : `$${Math.round(total).toLocaleString("en-US")} / month for ${seats} seats${yearly ? ", billed yearly" : ""}`}</p>
209 </div>
210 );
211}
212
213export function Demo() {
214 const [theme, setTheme] = useState<"light" | "dark">("light");
215 const [billing, setBilling] = useState("yearly");
216 const [seats, setSeats] = useState("10");
217
218 return (
219 <div className="fd-seg-demo" data-fd-theme={theme}>
220 <style>{demoCss}</style>
221 <header className="fd-seg-demo-bar">
222 <span className="fd-seg-demo-brand"><i />Trailhead</span>
223 <button type="button" className="fd-seg-demo-theme" aria-label={theme === "dark" ? "Switch to light theme" : "Switch to dark theme"} onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>{theme === "dark" ? "Light" : "Dark"}</button>
224 </header>
225
226 <main className="fd-seg-demo-main">
227 <p className="fd-seg-demo-eyebrow">Pricing</p>
228 <h1>Simple pricing for teams that ship.</h1>
229 <p className="fd-seg-demo-sub">Start free. Upgrade when your release cadence outgrows the basics.</p>
230
231 <div className="fd-seg-demo-controls">
232 <SegmentedControl
233 aria-label="Billing period"
234 size="lg"
235 value={billing}
236 onChange={setBilling}
237 options={[
238 { value: "monthly", label: "Monthly" },
239 { value: "yearly", label: "Yearly", badge: "−20%" },
240 ]}
241 />
242 <div className="fd-seg-demo-seats">
243 <span id="fd-seg-seats">Seats</span>
244 <SegmentedControl
245 aria-labelledby="fd-seg-seats"
246 size="sm"
247 value={seats}
248 onChange={setSeats}
249 options={[{ value: "5", label: "5" }, { value: "10", label: "10" }, { value: "25", label: "25" }, { value: "50", label: "50" }]}
250 />
251 </div>
252 </div>
253
254 <div className="fd-seg-demo-grid">
255 {PLANS.map((p) => (
256 <section key={p.id} className="fd-seg-demo-plan" data-popular={p.popular || undefined} aria-labelledby={`fd-seg-${p.id}`}>
257 <div className="fd-seg-demo-plan-head">
258 <h2 id={`fd-seg-${p.id}`}>{p.name}</h2>
259 {p.popular && <span className="fd-seg-demo-pop">Most popular</span>}
260 </div>
261 <p className="fd-seg-demo-blurb">{p.blurb}</p>
262 <Price plan={p} yearly={billing === "yearly"} seats={Number(seats)} />
263 <ul>
264 {p.feats.map((f) => (
265 <li key={f}>
266 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>
267 {f}
268 </li>
269 ))}
270 </ul>
271 </section>
272 ))}
273 </div>
274 </main>
275 </div>
276 );
277}
278
279const demoCss = `
280:where(.fd-seg-demo,.fd-seg-demo *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none}
281:where(.fd-seg-demo,.fd-seg-demo *)::before,:where(.fd-seg-demo,.fd-seg-demo *)::after{box-sizing:border-box}
282:where(.fd-seg-demo svg){display:block;flex-shrink:0}
283:where(.fd-seg-demo button){appearance:none;-webkit-appearance:none;background:none;border:0;border-radius:0}
284.fd-seg-demo{--d-font:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;--d-disp:ui-sans-serif,-apple-system,"SF Pro Display","Inter",sans-serif;
285 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-dur-2:240ms;
286 --d-bg:#F7F7F9;--d-card:#fff;--d-ink:#0B0B0F;--d-mut:#6B6B76;--d-line:rgba(11,11,15,.08);--d-soft:rgba(11,11,15,.05);--d-accent:#007AFF;
287 min-height:100vh;background:var(--d-bg);color:var(--d-ink);font:400 15px/1.45 var(--d-font);-webkit-font-smoothing:antialiased;transition:background-color var(--fd-dur-2) var(--fd-ease-out)}
288.fd-seg-demo[data-fd-theme="dark"]{--d-bg:#09090B;--d-card:#111115;--d-ink:#F5F5F7;--d-mut:#9A9AA6;--d-line:rgba(255,255,255,.09);--d-soft:rgba(255,255,255,.06);--d-accent:#0A84FF}
289.fd-seg-demo-bar{display:flex;align-items:center;justify-content:space-between;height:60px;padding:0 clamp(16px,4vw,40px)}
290.fd-seg-demo-brand{display:flex;align-items:center;gap:10px;font:600 17px/1 var(--d-disp);letter-spacing:-.02em}
291.fd-seg-demo-brand i{display:block;width:18px;height:18px;border-radius:5px;background:var(--d-ink);clip-path:polygon(0 100%,50% 0,100% 100%)}
292.fd-seg-demo-theme{cursor:pointer;height:32px;padding:0 14px;border-radius:9999px;box-shadow:0 0 0 1px var(--d-line);font:500 13px/1 var(--d-font);color:var(--d-mut)}
293.fd-seg-demo-theme:hover{color:var(--d-ink)}.fd-seg-demo-theme:focus-visible{outline:2px solid rgba(10,132,255,.7);outline-offset:2px}
294.fd-seg-demo-main{max-width:1040px;margin:0 auto;padding:clamp(24px,6vh,64px) clamp(16px,4vw,40px) 64px;text-align:center}
295.fd-seg-demo-eyebrow{font:500 13px/1 var(--d-font);color:var(--d-accent)}
296.fd-seg-demo-main h1{margin-top:16px;font:600 clamp(32px,5vw,44px)/1.08 var(--d-disp);letter-spacing:-.03em}
297.fd-seg-demo-sub{margin:14px auto 0;max-width:460px;font:400 17px/1.5 var(--d-font);letter-spacing:-.01em;color:var(--d-mut)}
298.fd-seg-demo-controls{display:flex;flex-direction:column;align-items:center;gap:16px;margin-top:32px}
299.fd-seg-demo-seats{display:flex;align-items:center;gap:12px;font:500 13px/1 var(--d-font);color:var(--d-mut)}
300.fd-seg-demo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-top:40px;text-align:left}
301.fd-seg-demo-plan{display:flex;flex-direction:column;gap:16px;padding:24px;border-radius:24px;background:var(--d-card);box-shadow:0 0 0 1px var(--d-line),0 1px 2px rgba(0,0,0,.04),0 16px 32px -24px rgba(0,0,0,.2)}
302.fd-seg-demo-plan[data-popular]{box-shadow:0 0 0 1.5px var(--d-accent),0 1px 2px rgba(0,0,0,.04),0 24px 48px -24px rgba(0,122,255,.35)}
303.fd-seg-demo-plan-head{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:24px}
304.fd-seg-demo-plan h2{font:600 17px/1.2 var(--d-font);letter-spacing:-.01em}
305.fd-seg-demo-pop{height:22px;display:inline-flex;align-items:center;padding:0 9px;border-radius:9999px;background:var(--d-accent);color:#fff;font:600 11px/1 var(--d-font)}
306.fd-seg-demo-blurb{font:400 14px/1.4 var(--d-font);color:var(--d-mut);min-height:40px}
307.fd-seg-demo-price{padding-bottom:16px;border-bottom:1px solid var(--d-line)}
308.fd-seg-demo-amt{font:600 44px/1 var(--d-disp);letter-spacing:-.03em;font-variant-numeric:tabular-nums}
309.fd-seg-demo-per{margin-left:6px;font:400 14px/1 var(--d-font);color:var(--d-mut)}
310.fd-seg-demo-price p{margin-top:10px;font:400 13px/1.3 var(--d-font);color:var(--d-mut);font-variant-numeric:tabular-nums;min-height:17px}
311.fd-seg-demo-plan ul{display:flex;flex-direction:column;gap:12px;font:400 14px/1.3 var(--d-font)}
312.fd-seg-demo-plan li{display:flex;align-items:center;gap:10px}.fd-seg-demo-plan li svg{color:var(--d-accent)}
313@media (max-width:860px){.fd-seg-demo-grid{grid-template-columns:1fr}.fd-seg-demo-blurb{min-height:0}}
314`;

More Buttons. Same file contract.

Button Core

The complete button system: 5 variants, 3 sizes, icon slots, loading → success morph and a springy press.

Shimmer Button

A dark capsule with a travelling light sweep and a rotating border glow for your one important call to action.

Magnetic Button

A button that leans toward your cursor on a spring, with the label drifting further than the shell.