Split Button

A primary action fused to a menu chevron with full keyboard navigation, type-ahead and auto-flip.

  • Buttons
  • Developer tools
  • 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/split-button.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/buttons/split-button.tsx
1/**
2 * SplitButton — a primary action joined to a chevron that opens a menu of
3 * related actions (deploy / schedule / roll back, publish / schedule…).
4 *
5 * Props: label, onPrimary, items[] ({ id, label, description?, icon?,
6 * shortcut?, danger?, disabled?, separatorBefore? }), onSelect(item),
7 * variant ("primary" | "secondary"), size ("md" | "lg"), icon, align
8 * ("start" | "end"), menuLabel, theme, disabled.
9 *
10 * Browser support: all evergreen browsers. The menu flips above the button
11 * when there is not enough room below.
12 * Accessibility: WAI-ARIA menu button pattern. The chevron has
13 * aria-haspopup="menu" / aria-expanded / aria-controls; the menu is
14 * role="menu" with role="menuitem" children and roving focus. Keys:
15 * ArrowDown/Up open + move, Home/End, type-ahead, Enter/Space activate,
16 * Escape closes and returns focus, Tab closes. Reduced motion: no scale.
17 */
18import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
19import type { KeyboardEvent, ReactNode } from "react";
20
21export interface SplitItem {
22 id: string;
23 label: string;
24 description?: string;
25 icon?: ReactNode;
26 shortcut?: string;
27 danger?: boolean;
28 disabled?: boolean;
29 separatorBefore?: boolean;
30}
31
32export interface SplitButtonProps {
33 label: ReactNode;
34 items: SplitItem[];
35 onPrimary?: () => void;
36 onSelect?: (item: SplitItem) => void;
37 variant?: "primary" | "secondary";
38 size?: "md" | "lg";
39 icon?: ReactNode;
40 align?: "start" | "end";
41 menuLabel?: string;
42 disabled?: boolean;
43 theme?: "light" | "dark";
44 className?: string;
45}
46
47export function SplitButton({
48 label,
49 items,
50 onPrimary,
51 onSelect,
52 variant = "primary",
53 size = "md",
54 icon,
55 align = "end",
56 menuLabel = "More actions",
57 disabled,
58 theme,
59 className,
60}: SplitButtonProps) {
61 const [open, setOpen] = useState(false);
62 const [flip, setFlip] = useState(false);
63 const root = useRef<HTMLSpanElement>(null);
64 const toggle = useRef<HTMLButtonElement>(null);
65 const menu = useRef<HTMLDivElement>(null);
66 const menuId = useId();
67 const typed = useRef({ s: "", t: 0 });
68
69 const focusItem = useCallback((i: number) => {
70 const els = menu.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not([disabled])');
71 if (!els || !els.length) return;
72 els[(i + els.length) % els.length].focus();
73 }, []);
74 const close = useCallback((refocus = true) => {
75 setOpen(false);
76 if (refocus) toggle.current?.focus();
77 }, []);
78
79 // flip above when there is no room below
80 useLayoutEffect(() => {
81 if (!open || !root.current || !menu.current) return;
82 const r = root.current.getBoundingClientRect();
83 const h = menu.current.offsetHeight + 12;
84 setFlip(window.innerHeight - r.bottom < h && r.top > h);
85 }, [open]);
86
87 useEffect(() => {
88 if (!open) return;
89 const onDown = (e: PointerEvent) => {
90 if (root.current && !root.current.contains(e.target as Node)) setOpen(false);
91 };
92 document.addEventListener("pointerdown", onDown);
93 return () => document.removeEventListener("pointerdown", onDown);
94 }, [open]);
95
96 const openAt = (where: "first" | "last") => {
97 setOpen(true);
98 requestAnimationFrame(() => focusItem(where === "first" ? 0 : -1));
99 };
100
101 const onToggleKey = (e: KeyboardEvent) => {
102 if (e.key === "ArrowDown") {
103 e.preventDefault();
104 openAt("first");
105 } else if (e.key === "ArrowUp") {
106 e.preventDefault();
107 openAt("last");
108 }
109 };
110
111 const onMenuKey = (e: KeyboardEvent) => {
112 const els = Array.from(menu.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not([disabled])') ?? []);
113 const cur = els.indexOf(document.activeElement as HTMLButtonElement);
114 if (e.key === "ArrowDown") (e.preventDefault(), focusItem(cur + 1));
115 else if (e.key === "ArrowUp") (e.preventDefault(), focusItem(cur - 1));
116 else if (e.key === "Home") (e.preventDefault(), focusItem(0));
117 else if (e.key === "End") (e.preventDefault(), focusItem(-1));
118 else if (e.key === "Escape") (e.preventDefault(), close());
119 else if (e.key === "Tab") setOpen(false);
120 else if (e.key.length === 1 && /\S/.test(e.key)) {
121 const t = typed.current;
122 window.clearTimeout(t.t);
123 t.s += e.key.toLowerCase();
124 t.t = window.setTimeout(() => (t.s = ""), 600);
125 const hit = els.findIndex((el) => (el.textContent ?? "").trim().toLowerCase().startsWith(t.s));
126 if (hit >= 0) focusItem(hit);
127 }
128 };
129
130 const choose = (it: SplitItem) => {
131 setOpen(false);
132 toggle.current?.focus();
133 onSelect?.(it);
134 };
135
136 return (
137 <span ref={root} className={`fd-split${className ? " " + className : ""}`} data-variant={variant} data-size={size} data-align={align} data-open={open || undefined} data-flip={flip || undefined} data-fd-theme={theme}>
138 <style>{css}</style>
139 <span className="fd-split-group" role="group">
140 <button type="button" className="fd-split-main" disabled={disabled} onClick={onPrimary}>
141 {icon && <span className="fd-split-ic">{icon}</span>}
142 <span>{label}</span>
143 </button>
144 <span className="fd-split-sep" aria-hidden="true" />
145 <button
146 ref={toggle}
147 type="button"
148 className="fd-split-chev"
149 aria-label={menuLabel}
150 aria-haspopup="menu"
151 aria-expanded={open}
152 aria-controls={open ? menuId : undefined}
153 disabled={disabled}
154 onClick={() => (open ? setOpen(false) : openAt("first"))}
155 onKeyDown={onToggleKey}
156 >
157 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>
158 </button>
159 </span>
160
161 {open && (
162 <div ref={menu} id={menuId} className="fd-split-menu" role="menu" aria-label={menuLabel} onKeyDown={onMenuKey}>
163 {items.map((it) => (
164 <span key={it.id} className="fd-split-row" data-sep={it.separatorBefore || undefined}>
165 <button
166 type="button"
167 role="menuitem"
168 className="fd-split-item"
169 data-danger={it.danger || undefined}
170 disabled={it.disabled}
171 tabIndex={-1}
172 onClick={() => choose(it)}
173 >
174 {it.icon && <span className="fd-split-ic">{it.icon}</span>}
175 <span className="fd-split-txt">
176 <span className="fd-split-t">{it.label}</span>
177 {it.description && <span className="fd-split-d">{it.description}</span>}
178 </span>
179 {it.shortcut && <kbd className="fd-split-kbd">{it.shortcut}</kbd>}
180 </button>
181 </span>
182 ))}
183 </div>
184 )}
185 </span>
186 );
187}
188
189const css = `
190:where(.fd-split,.fd-split *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none}
191:where(.fd-split,.fd-split *)::before,:where(.fd-split,.fd-split *)::after{box-sizing:border-box}
192:where(.fd-split svg){display:block;flex-shrink:0}
193:where(.fd-split button){appearance:none;-webkit-appearance:none;background:none;border:0;border-radius:0}
194.fd-split{
195 --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;
196 --s-bg:#007AFF;--s-bg-h:#1A8CFF;--s-fg:#fff;--s-sep:rgba(255,255,255,.28);--s-ring:rgba(0,122,255,.7);
197 --s-sh:0 1px 0 rgba(255,255,255,.22) inset,0 1px 2px rgba(0,40,120,.35),0 8px 18px -10px #007AFF;
198 --m-bg:#fff;--m-fg:#0B0B0F;--m-mut:#6B6B76;--m-line:rgba(11,11,15,.1);--m-hover:rgba(11,11,15,.05);--m-danger:#D70015;
199 --m-sh:0 0 0 1px rgba(11,11,15,.08),0 2px 4px rgba(11,11,15,.04),0 16px 40px -12px rgba(11,11,15,.25);
200 position:relative;display:inline-flex;vertical-align:middle;
201 font:500 15px/1 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;letter-spacing:-.005em;color:var(--m-fg);
202}
203[data-fd-theme="dark"] .fd-split,.fd-split[data-fd-theme="dark"]{
204 --s-bg:#0A84FF;--s-bg-h:#3B9CFF;--s-ring:rgba(10,132,255,.85);
205 --m-bg:#1C1C22;--m-fg:#F5F5F7;--m-mut:#9A9AA6;--m-line:rgba(255,255,255,.08);--m-hover:rgba(255,255,255,.07);--m-danger:#FF6A60;
206 --m-sh:0 0 0 1px rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.06) inset,0 2px 4px rgba(0,0,0,.3),0 24px 48px -12px rgba(0,0,0,.7)}
207.fd-split[data-size="md"]{--s-h:40px;--s-fs:15px;--s-px:16px}
208.fd-split[data-size="lg"]{--s-h:48px;--s-fs:17px;--s-px:20px}
209.fd-split-group{position:relative;display:inline-flex;align-items:stretch;height:var(--s-h);border-radius:9999px;background:var(--s-bg);color:var(--s-fg);box-shadow:var(--s-sh);transition:box-shadow var(--fd-dur-2) var(--fd-ease-out)}
210.fd-split[data-variant="secondary"]{--s-bg:var(--m-bg);--s-fg:var(--m-fg);--s-sep:var(--m-line);--s-bg-h:var(--m-hover);--s-sh:0 0 0 1px var(--m-line),0 1px 2px rgba(11,11,15,.06)}
211.fd-split-main,.fd-split-chev{appearance:none;-webkit-appearance:none;border:0;outline:0;cursor:pointer;user-select:none;-webkit-tap-highlight-color:transparent;background:transparent;color:inherit;
212 display:inline-flex;align-items:center;justify-content:center;gap:8px;font-size:var(--s-fs);font-weight:500;line-height:1;font-family:inherit;letter-spacing:inherit;
213 transform:scale(1);transition:transform var(--fd-dur-3) var(--fd-spring),background-color var(--fd-dur-1) var(--fd-ease-out)}
214.fd-split-main{padding:0 var(--s-px) 0 calc(var(--s-px) + 2px);border-radius:9999px 0 0 9999px;white-space:nowrap}
215.fd-split-chev{width:calc(var(--s-h) - 2px);padding:0;border-radius:0 9999px 9999px 0}
216.fd-split-chev svg{transition:transform var(--fd-dur-3) var(--fd-spring)}
217.fd-split[data-open] .fd-split-chev svg{transform:rotate(180deg)}
218.fd-split-sep{width:1px;margin:10px 0;background:var(--s-sep)}
219@media (hover:hover){.fd-split-main:hover:not(:disabled),.fd-split-chev:hover:not(:disabled){background:var(--s-bg-h)}}
220.fd-split[data-variant="primary"] .fd-split-main:hover:not(:disabled),.fd-split[data-variant="primary"] .fd-split-chev:hover:not(:disabled){background:rgba(255,255,255,.14)}
221.fd-split-main:active,.fd-split-chev:active{transform:scale(.96);transition:transform var(--fd-dur-1) var(--fd-ease-out)}
222.fd-split-main:focus-visible,.fd-split-chev:focus-visible{outline:2px solid var(--s-ring);outline-offset:2px}
223.fd-split-main:disabled,.fd-split-chev:disabled{opacity:.45;cursor:not-allowed}
224.fd-split-ic{display:grid;place-items:center;width:18px;height:18px}.fd-split-ic svg{width:100%;height:100%}
225
226.fd-split-menu{position:absolute;z-index:20;top:calc(100% + 8px);min-width:300px;padding:6px;border-radius:16px;background:var(--m-bg);box-shadow:var(--m-sh);transform-origin:top right;animation:fd-split-in var(--fd-dur-2) var(--fd-ease-out);outline:0}
227.fd-split[data-align="end"] .fd-split-menu{right:0}
228.fd-split[data-align="start"] .fd-split-menu{left:0;transform-origin:top left}
229.fd-split[data-flip] .fd-split-menu{top:auto;bottom:calc(100% + 8px);transform-origin:bottom right}
230.fd-split-row{display:block}
231.fd-split-row[data-sep]{margin-top:6px;padding-top:6px;border-top:1px solid var(--m-line)}
232.fd-split-item{appearance:none;-webkit-appearance:none;display:flex;align-items:center;gap:12px;width:100%;padding:10px 12px;border:0;outline:0;border-radius:10px;background:transparent;color:var(--m-fg);cursor:pointer;text-align:left;font-family:inherit;transition:background-color var(--fd-dur-1) var(--fd-ease-out)}
233.fd-split-item:hover:not(:disabled),.fd-split-item:focus-visible{background:var(--m-hover)}
234.fd-split-item:focus-visible{box-shadow:0 0 0 2px var(--s-ring) inset}
235.fd-split-item:disabled{opacity:.4;cursor:not-allowed}
236.fd-split-item[data-danger]{color:var(--m-danger)}
237.fd-split-item .fd-split-ic{color:var(--m-mut)}.fd-split-item[data-danger] .fd-split-ic{color:var(--m-danger)}
238.fd-split-txt{display:flex;flex-direction:column;gap:3px;flex:1;min-width:0}
239.fd-split-t{font-size:14px;font-weight:500;line-height:1.2}
240.fd-split-d{font-size:12px;font-weight:400;line-height:1.35;color:var(--m-mut)}
241.fd-split-kbd{font:500 11px/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;color:var(--m-mut);padding:4px 6px;border-radius:6px;background:var(--m-hover)}
242@keyframes fd-split-in{from{opacity:0;transform:translateY(-4px) scale(.96)}}
243.fd-split[data-flip] .fd-split-menu{animation-name:fd-split-in-up}
244@keyframes fd-split-in-up{from{opacity:0;transform:translateY(4px) scale(.96)}}
245@media (prefers-reduced-motion:reduce){.fd-split-main,.fd-split-chev,.fd-split-chev svg{transition:none}.fd-split-main:active,.fd-split-chev:active{transform:none}.fd-split-menu{animation:fd-split-fade 100ms linear}}
246@keyframes fd-split-fade{from{opacity:0}}
247@media (prefers-reduced-transparency:reduce),(prefers-contrast:more){.fd-split-menu{box-shadow:0 0 0 1px currentColor}}
248`;
249
250/* ───────────────────────────── demo ───────────────────────────── */
251
252const ic = {
253 rocket: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 15c-1.5 1.3-2 5-2 5s3.7-.5 5-2M12 15l-3-3a22 22 0 0 1 2-4 12 12 0 0 1 11-5c0 3-1 8-5 11a22 22 0 0 1-4 2zM9 12H4s.6-3 2-4c1.6-1 5 0 5 0M12 15v5s3-.6 4-2c1-1.6 0-5 0-5" /></svg>,
254 clock: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>,
255 layers: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l9 5-9 5-9-5 9-5zM3 13l9 5 9-5" /></svg>,
256 refresh: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 11a8 8 0 0 0-14-4L4 9M4 4v5h5M4 13a8 8 0 0 0 14 4l2-2M20 20v-5h-5" /></svg>,
257 undo: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 14L4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3" /></svg>,
258};
259
260const DEPLOYS = [
261 { id: "d1", v: "v2.15.0", msg: "Faster invoice export", who: "Priya N.", when: "12 min ago", state: "live" },
262 { id: "d2", v: "v2.14.1", msg: "Fix retry on webhook 429", who: "Marcus T.", when: "Yesterday, 16:42", state: "ready" },
263 { id: "d3", v: "v2.14.0", msg: "Postgres 16 migration", who: "Lena H.", when: "Mon, 09:18", state: "ready" },
264];
265
266export function Demo() {
267 const [theme, setTheme] = useState<"light" | "dark">("light");
268 const [log, setLog] = useState<{ id: number; text: string }[]>([]);
269 const add = (text: string) => setLog((l) => [{ id: Date.now() + l.length, text }, ...l].slice(0, 4));
270
271 return (
272 <div className="fd-split-demo" data-fd-theme={theme}>
273 <style>{demoCss}</style>
274 <header className="fd-split-demo-bar">
275 <div className="fd-split-demo-crumb"><b>Meridian</b><span>/</span>billing-service<span>/</span>Deployments</div>
276 <button type="button" className="fd-split-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>
277 </header>
278 <main className="fd-split-demo-main">
279 <div className="fd-split-demo-head">
280 <div>
281 <h1>Deployments</h1>
282 <p>main · 3 commits ahead of production</p>
283 </div>
284 <div className="fd-split-demo-actions">
285 <SplitButton
286 variant="secondary"
287 label="Redeploy"
288 icon={ic.refresh}
289 menuLabel="More redeploy options"
290 onPrimary={() => add("Redeploying v2.15.0 to production")}
291 onSelect={(i) => add(`${i.label} queued`)}
292 items={[
293 { id: "cache", label: "Redeploy with cache cleared", icon: ic.layers },
294 { id: "env", label: "Redeploy with new env vars", icon: ic.refresh },
295 ]}
296 />
297 <SplitButton
298 label="Deploy to production"
299 icon={ic.rocket}
300 onPrimary={() => add("Deploying main@a41f9c2 to production")}
301 onSelect={(i) => add(i.id === "rollback" ? "Rolling back to v2.14.1" : `${i.label}`)}
302 items={[
303 { id: "staging", label: "Deploy to staging", description: "staging.billing.meridian.dev", icon: ic.layers, shortcut: "⌘ S" },
304 { id: "schedule", label: "Schedule deploy…", description: "Pick a low-traffic window", icon: ic.clock, shortcut: "⌘ ⇧ S" },
305 { id: "canary", label: "Canary 10% for 30 min", description: "Auto-promote if error rate < 0.5%", icon: ic.rocket },
306 { id: "rollback", label: "Roll back to v2.14.1", icon: ic.undo, danger: true, separatorBefore: true },
307 { id: "frozen", label: "Deploy freeze active until Fri", icon: ic.clock, disabled: true },
308 ]}
309 />
310 </div>
311 </div>
312
313 <section className="fd-split-demo-list" aria-label="Recent deployments">
314 {DEPLOYS.map((d) => (
315 <div key={d.id} className="fd-split-demo-row">
316 <span className="fd-split-demo-dot" data-state={d.state} />
317 <div className="fd-split-demo-v"><b>{d.v}</b><span>{d.msg}</span></div>
318 <span className="fd-split-demo-who">{d.who}</span>
319 <span className="fd-split-demo-when">{d.when}</span>
320 <span className="fd-split-demo-tag" data-state={d.state}>{d.state === "live" ? "Production" : "Ready"}</span>
321 </div>
322 ))}
323 </section>
324
325 <section className="fd-split-demo-log" aria-live="polite" aria-label="Activity">
326 {log.length === 0 ? <p>Choose an action to see it logged here.</p> : log.map((l) => <p key={l.id}><i /> {l.text} <small>just now</small></p>)}
327 </section>
328 </main>
329 </div>
330 );
331}
332
333const demoCss = `
334:where(.fd-split-demo,.fd-split-demo *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none}
335:where(.fd-split-demo,.fd-split-demo *)::before,:where(.fd-split-demo,.fd-split-demo *)::after{box-sizing:border-box}
336:where(.fd-split-demo button){appearance:none;-webkit-appearance:none;background:none;border:0;border-radius:0}
337.fd-split-demo{--d-font:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;--d-mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;
338 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-dur-2:240ms;
339 --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);
340 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)}
341.fd-split-demo[data-fd-theme="dark"]{--d-bg:#09090B;--d-card:#101014;--d-ink:#F5F5F7;--d-mut:#8E8E9A;--d-line:rgba(255,255,255,.08);--d-soft:rgba(255,255,255,.06)}
342.fd-split-demo-bar{display:flex;align-items:center;justify-content:space-between;height:56px;padding:0 clamp(16px,4vw,32px);border-bottom:1px solid var(--d-line);font:400 14px/1 var(--d-font);color:var(--d-mut)}
343.fd-split-demo-crumb{display:flex;gap:10px;align-items:center}.fd-split-demo-crumb b{color:var(--d-ink);font-weight:600}.fd-split-demo-crumb span{opacity:.5}
344.fd-split-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)}
345.fd-split-demo-theme:hover{color:var(--d-ink)}.fd-split-demo-theme:focus-visible{outline:2px solid rgba(10,132,255,.7);outline-offset:2px}
346.fd-split-demo-main{max-width:960px;margin:0 auto;padding:40px clamp(16px,4vw,32px) 64px;display:flex;flex-direction:column;gap:24px}
347.fd-split-demo-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap}
348.fd-split-demo-head h1{font:600 24px/1.15 var(--d-font);letter-spacing:-.02em}
349.fd-split-demo-head p{margin-top:6px;font:400 14px/1.4 var(--d-mono);color:var(--d-mut)}
350.fd-split-demo-actions{display:flex;gap:12px;flex-wrap:wrap;align-items:center}
351.fd-split-demo-list{border-radius:20px;background:var(--d-card);box-shadow:0 0 0 1px var(--d-line),0 1px 2px rgba(0,0,0,.04),0 16px 32px -20px rgba(0,0,0,.2);overflow:hidden}
352.fd-split-demo-row{display:grid;grid-template-columns:12px minmax(0,1fr) 120px 140px 96px;align-items:center;gap:16px;padding:16px 20px;border-top:1px solid var(--d-line);font:400 14px/1.3 var(--d-font)}
353.fd-split-demo-row:first-child{border-top:0}
354.fd-split-demo-dot{width:8px;height:8px;border-radius:50%;background:var(--d-mut)}.fd-split-demo-dot[data-state="live"]{background:#30D158;box-shadow:0 0 0 4px rgba(48,209,88,.18)}
355.fd-split-demo-v{display:flex;flex-direction:column;gap:3px;min-width:0}.fd-split-demo-v b{font:600 14px/1.2 var(--d-mono)}.fd-split-demo-v span{color:var(--d-mut);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
356.fd-split-demo-who,.fd-split-demo-when{color:var(--d-mut)}
357.fd-split-demo-tag{justify-self:end;height:22px;display:inline-flex;align-items:center;padding:0 8px;border-radius:9999px;background:var(--d-soft);font:500 12px/1 var(--d-font);color:var(--d-mut)}
358.fd-split-demo-tag[data-state="live"]{background:rgba(48,209,88,.14);color:#1C9A44}
359.fd-split-demo[data-fd-theme="dark"] .fd-split-demo-tag[data-state="live"]{color:#5BE382}
360.fd-split-demo-log{min-height:132px;padding:16px 20px;border-radius:16px;background:var(--d-soft);display:flex;flex-direction:column;gap:10px;font:400 13px/1.3 var(--d-mono)}
361.fd-split-demo-log p{display:flex;align-items:center;gap:10px;color:var(--d-ink)}.fd-split-demo-log p:only-child:first-child{color:var(--d-mut)}
362.fd-split-demo-log i{width:6px;height:6px;border-radius:50%;background:#0A84FF}.fd-split-demo-log small{margin-left:auto;font-size:12px;color:var(--d-mut)}
363@media (max-width:720px){.fd-split-demo-row{grid-template-columns:12px minmax(0,1fr) auto}.fd-split-demo-who,.fd-split-demo-when{display:none}.fd-split-demo-actions{width:100%}}
364`;

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.