Button Core
The complete button system: 5 variants, 3 sizes, icon slots, loading → success morph and a springy press.
A radio-group toggle with a springy sliding thumb, badges and arrow-key selection.
npx shadcn@latest add https://freedesignmd.com/r/segmented-control.json1/**2 * SegmentedControl — a single-choice control with a thumb that slides3 * 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 from11 * measured offsets (ResizeObserver) so any label width works.12 * Accessibility: role="radiogroup" with role="radio" options and a roving13 * 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 ...aria50}: 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 <button104 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 <SegmentedControl233 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 <SegmentedControl245 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`;The complete button system: 5 variants, 3 sizes, icon slots, loading → success morph and a springy press.
A dark capsule with a travelling light sweep and a rotating border glow for your one important call to action.
A button that leans toward your cursor on a spring, with the label drifting further than the shell.