Chapter 04 · Actions & inputs

Buttons & controls. Every tap, on brand.

Buttons are where brand voice meets user intent. One hero per screen, everything else is quieter. Controls follow the same discipline.

4.1 Primary button

The one hero action per screen. Pink fill, pink shadow, ink-on-white is never primary. Use no more than one per viewport.

Primary

.btn.btn-primary

Lift 1px on hover, press back flush on click. Disabled drops opacity without changing colour.

<!-- 1. With trailing arrow icon -->
<button class="btn btn-primary">
  Book a demo
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round">
    <path d="M5 12h14M13 5l7 7-7 7"/>
  </svg>
</button>

<!-- 2. Plain label -->
<button class="btn btn-primary">Start free trial</button>

<!-- 3. Disabled — opacity drops, colour stays -->
<button class="btn btn-primary" disabled>Processing…</button>
// npm install @magicblocksai/ui @magicblocksai/css
import { Button } from "@magicblocksai/ui";

export default function Demo() {
  return (
    <>
      {/* 1. With trailing arrow icon */}
      <Button variant="primary">
        Book a demo
        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
          <path d="M5 12h14M13 5l7 7-7 7" />
        </svg>
      </Button>

      {/* 2. Plain label */}
      <Button variant="primary">Start free trial</Button>

      {/* 3. Disabled */}
      <Button variant="primary" disabled>Processing…</Button>
    </>
  );
}
.btn {
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--s-2);
  font: 600 14.5px/1 var(--f-display);
  letter-spacing: -0.005em;
  padding: 11px var(--s-5);
  border: 1px solid transparent;
  border-radius: var(--r-md);
  cursor: pointer;
  transition: background var(--dur-2) var(--ease),
              border-color var(--dur-2) var(--ease),
              transform var(--dur-2) var(--ease),
              box-shadow var(--dur-2) var(--ease),
              color var(--dur-2) var(--ease);
  user-select: none;
  white-space: nowrap;
}
.btn:focus-visible { outline: 0; box-shadow: var(--sh-focus); }
.btn:disabled { opacity: .55; cursor: not-allowed; pointer-events: none; }

.btn-primary {
  background: var(--accent); color: var(--paper);
  box-shadow: var(--sh-pink);
}
.btn-primary:hover   { transform: translateY(-1px); filter: brightness(1.04); }
.btn-primary:active  { transform: translateY(0); filter: brightness(0.96); }

4.2 Secondary button

Partner to the primary. White card surface with a hair border that darkens to ink on hover. Pair with primary in hero rows.

Secondary

.btn.btn-secondary

<!-- 1. Plain label -->
<button class="btn btn-secondary">Learn more</button>

<!-- 2. With trailing arrow icon -->
<button class="btn btn-secondary">
  See it in action
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round">
    <path d="M5 12h14M13 5l7 7-7 7"/>
  </svg>
</button>

<!-- 3. Disabled -->
<button class="btn btn-secondary" disabled>Coming soon</button>
.btn-secondary {
  background: var(--bg-paper);
  color: var(--fg);
  border-color: var(--hair);
}
.btn-secondary:hover {
  border-color: var(--fg);
  background: var(--bg-paper);
  transform: translateY(-1px);
}
.btn-secondary:active { transform: translateY(0); }
import { Button } from "@magicblocksai/ui";

export default function Demo() {
  return (
    <>
      <Button variant="secondary">Learn more</Button>
      <Button variant="secondary">
        See it in action
        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25">
          <path d="M5 12h14M13 5l7 7-7 7" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
      </Button>
      <Button variant="secondary" disabled>Coming soon</Button>
    </>
  );
}

4.3 Ghost / tertiary button

The quietest button. Use for dismiss, cancel, or any low-stakes action inside a dense panel. No fill until hovered.

Ghost

.btn.btn-ghost

<!-- 1. Plain dismiss -->
<button class="btn btn-ghost">Skip</button>

<!-- 2. With trailing arrow icon -->
<button class="btn btn-ghost">
  View docs
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round">
    <path d="M5 12h14M13 5l7 7-7 7"/>
  </svg>
</button>

<!-- 3. Cancel -->
<button class="btn btn-ghost">Cancel</button>
.btn-ghost {
  background: transparent;
  color: var(--fg);
  border-color: transparent;
}
.btn-ghost:hover {
  background: var(--bg-sunk);
  border-color: var(--hair);
}
import { Button } from "@magicblocksai/ui";

export default function Demo() {
  return (
    <>
      <Button variant="ghost">Skip</Button>
      <Button variant="ghost">
        View docs
        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25">
          <path d="M5 12h14M13 5l7 7-7 7" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
      </Button>
      <Button variant="ghost">Cancel</Button>
    </>
  );
}

4.4 Danger button

Destructive actions only. There's only one red in the system — D64545 — and this is where it lives.

Danger + danger-outline

.btn-danger · .btn-danger-outline

Always confirm destructive actions in a modal before executing.

<!-- 1. Solid danger -->
<button class="btn btn-danger">Delete agent</button>

<!-- 2. Outline danger -->
<button class="btn btn-danger-outline">Remove workspace</button>
.btn-danger {
  background: var(--error); color: var(--paper);
}
.btn-danger:hover { filter: brightness(1.05); transform: translateY(-1px); }

.btn-danger-outline {
  background: transparent; color: var(--error-text);
  border-color: rgba(214, 69, 69, 0.3);
}
.btn-danger-outline:hover {
  background: var(--error-soft);
  border-color: var(--error);
}
import { Button } from "@magicblocksai/ui";

export default function Demo() {
  return (
    <>
      <Button variant="danger">Delete agent</Button>
      <Button variant="danger-outline">Remove workspace</Button>
    </>
  );
}

4.5 Sizes

Four sizes, one default. Hero size is for landing-page CTAs only. Small is for dense UIs (tables, toolbars).

Size scale

sm · md · lg · xl

<button class="btn btn-primary btn-sm">Small</button>      <!-- 13px · dense UIs -->
<button class="btn btn-primary">Default</button>           <!-- 14.5px · the workhorse -->
<button class="btn btn-primary btn-lg">Large</button>     <!-- 15.5px -->
<button class="btn btn-primary btn-xl">Hero</button>      <!-- 17px · landing CTA only -->
.btn-sm { padding: 7px var(--s-4); font-size: 13px; border-radius: var(--r-sm); }
.btn     { padding: 11px var(--s-5); font-size: 14.5px; }           /* default */
.btn-lg { padding: 13px var(--s-6); font-size: 15.5px; }
.btn-xl {
  padding: 16px var(--s-7);
  font-size: 17px;
  border-radius: var(--r-lg);
  gap: var(--s-3);
}
import { Button } from "@magicblocksai/ui";

export default function Demo() {
  return (
    <>
      <Button size="sm">Small</Button>
      <Button>Default</Button>
      <Button size="lg">Large</Button>
      <Button size="xl">Hero</Button>
    </>
  );
}

4.6 Icon button

Square, 36×36, always with an aria-label. Useful in toolbars and compact UIs. Never substitute for a labelled button on marketing surfaces.

Icon-only

.icon-btn

<!-- 1. Default neutral, plus icon -->
<button class="icon-btn" aria-label="Add">
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
</button>

<!-- 2. Primary (pink fill), continue arrow -->
<button class="icon-btn icon-btn-primary" aria-label="Continue">
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
</button>

<!-- 3. Close (×) -->
<button class="icon-btn" aria-label="Close">
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>

<!-- 4. Confirm (✓) -->
<button class="icon-btn" aria-label="Confirm">
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</button>
.icon-btn {
  width: 36px; height: 36px;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--bg-paper);
  border: 1px solid var(--hair);
  border-radius: var(--r-md);
  color: var(--fg);
  cursor: pointer;
  transition: background var(--dur-2) var(--ease),
              border-color var(--dur-2) var(--ease),
              color var(--dur-2) var(--ease);
}
.icon-btn:hover {
  border-color: var(--fg);
  color: var(--fg);
}
.icon-btn:focus-visible { outline: 0; box-shadow: var(--sh-focus); }

.icon-btn-primary {
  background: var(--accent); color: var(--paper);
  border-color: transparent;
}
.icon-btn-primary:hover { filter: brightness(1.05); color: var(--paper); }
import { IconButton } from "@magicblocksai/ui";

export default function Demo() {
  return (
    <>
      {/* 1. Default — plus icon */}
      <IconButton aria-label="Add">
        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25">
          <path d="M12 5v14M5 12h14" />
        </svg>
      </IconButton>

      {/* 2. Primary — continue arrow */}
      <IconButton variant="primary" aria-label="Continue">
        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25">
          <path d="M5 12h14M13 5l7 7-7 7" />
        </svg>
      </IconButton>

      {/* 3. Close × */}
      <IconButton aria-label="Close">
        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25">
          <path d="M18 6 6 18M6 6l12 12" />
        </svg>
      </IconButton>

      {/* 4. Confirm ✓ */}
      <IconButton aria-label="Confirm">
        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.75">
          <path d="M20 6 9 17l-5-5" />
        </svg>
      </IconButton>
    </>
  );
}

4.8 Button groups & split

Grouped secondary buttons form a horizontal bar for utility strips. Split buttons pair a primary action with a chevron for alternates.

Button group + split

.btn-group · .btn-split

<!-- 1. Button group: utility strip of equal-weight actions -->
<div class="btn-group" role="group">
  <button class="btn btn-secondary">Copy</button>
  <button class="btn btn-secondary">Edit</button>
  <button class="btn btn-secondary">Duplicate</button>
</div>

<!-- 2. Split button: primary action + chevron for alternates -->
<div class="btn-split">
  <button class="btn btn-primary btn-split-main">Continue</button>
  <button class="btn btn-primary btn-split-icon" aria-label="More options">
    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="m6 9 6 6 6-6"/></svg>
  </button>
</div>
.btn-group {
  display: inline-flex;
}
.btn-group .btn {
  border-radius: 0;
  border-right-width: 0;
}
.btn-group .btn:first-child { border-top-left-radius: var(--r-md); border-bottom-left-radius: var(--r-md); }
.btn-group .btn:last-child  { border-top-right-radius: var(--r-md); border-bottom-right-radius: var(--r-md); border-right-width: 1px; }
.btn-group .btn:hover { z-index: 1; position: relative; }

.btn-split { display: inline-flex; }
.btn-split-main { border-top-right-radius: 0; border-bottom-right-radius: 0; }
.btn-split-icon {
  padding: 11px var(--s-3);
  border-top-left-radius: 0;
  border-bottom-left-radius: 0;
  border-left: 1px solid rgba(255,255,255,0.25);
}
import { Button, ButtonGroup } from "@magicblocksai/ui";

export default function Demo() {
  return (
    <>
      {/* 1. Button group */}
      <ButtonGroup>
        <Button variant="secondary">Copy</Button>
        <Button variant="secondary">Edit</Button>
        <Button variant="secondary">Duplicate</Button>
      </ButtonGroup>

      {/* 2. Split button — main action + chevron */}
      <div className="btn-split">
        <Button variant="primary" className="btn-split-main">Continue</Button>
        <Button variant="primary" className="btn-split-icon" aria-label="More options">
          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
            <path d="m6 9 6 6 6-6" strokeLinecap="round" />
          </svg>
        </Button>
      </div>
    </>
  );
}

4.9 Loading state

Replace the trailing icon with a spinner and lock the button. Keep the label — silence feels broken.

Loading

.btn-loading

<!-- 1. Primary loading -->
<button class="btn btn-primary btn-loading">
  <span class="spinner"></span>
  Processing…
</button>

<!-- 2. Secondary loading -->
<button class="btn btn-secondary btn-loading">
  <span class="spinner"></span>
  Checking availability
</button>
.btn-loading { pointer-events: none; }
.spinner {
  width: 14px; height: 14px;
  border-radius: 50%;
  border: 2px solid currentColor;
  border-top-color: transparent;
  animation: spin 0.8s linear infinite;
  opacity: .85;
}
.btn-primary .spinner { border-color: rgba(255,255,255,.45); border-top-color: transparent; }
@keyframes spin { to { transform: rotate(360deg); } }
import { Button } from "@magicblocksai/ui";

export default function Demo() {
  return (
    <>
      <Button variant="primary" loading>Processing…</Button>
      <Button variant="secondary" loading>Checking availability</Button>
    </>
  );
}

4.10 Toggle (switch)

Two-state settings. Use switch for boolean preferences; use checkbox for selecting items.

Switch

.switch

<!-- 1. Checked -->
<label class="switch">
  <input type="checkbox" checked>
  <span class="switch-track" aria-hidden="true"></span>
  <span class="switch-label">Auto-follow-up</span>
</label>

<!-- 2. Unchecked -->
<label class="switch">
  <input type="checkbox">
  <span class="switch-track" aria-hidden="true"></span>
  <span class="switch-label">Send transcripts to CRM</span>
</label>

<!-- 3. Disabled -->
<label class="switch">
  <input type="checkbox" disabled>
  <span class="switch-track" aria-hidden="true"></span>
  <span class="switch-label">Voice calls (Pro)</span>
</label>
.switch {
  display: inline-flex; align-items: center; gap: var(--s-3);
  cursor: pointer;
  font: 500 14.5px/1.3 var(--f-body);
  color: var(--fg);
  user-select: none;
}
.switch input { position: absolute; opacity: 0; pointer-events: none; }
.switch-track {
  flex: 0 0 40px;
  width: 40px; height: 22px;
  background: var(--bg-deep);
  border-radius: var(--r-pill);
  position: relative;
  transition: background var(--dur-2) var(--ease);
}
.switch-track::after {
  content: ""; position: absolute;
  top: 2px; left: 2px;
  width: 18px; height: 18px; border-radius: 50%;
  background: var(--paper);
  box-shadow: var(--sh-1);
  transition: transform var(--dur-2) var(--ease);
}
.switch input:checked + .switch-track { background: var(--accent); }
.switch input:checked + .switch-track::after { transform: translateX(18px); }
.switch input:focus-visible + .switch-track { box-shadow: var(--sh-focus); }
.switch input:disabled ~ * { opacity: .5; }
.switch input:disabled { cursor: not-allowed; }
import { Switch } from "@magicblocksai/ui";

export default function Demo() {
  return (
    <>
      <Switch defaultChecked>Auto-follow-up</Switch>
      <Switch>Send transcripts to CRM</Switch>
      <Switch disabled>Voice calls (Pro)</Switch>
    </>
  );
}

4.11 Checkbox · radio

Pink fill when selected. Checkbox for many-from-many, radio for one-from-many. Never use checkboxes where a switch belongs.

Checkbox & radio

.cb · .rb

<!-- Checkboxes — many-from-many. .cb-disabled adds the dimmed state. -->
<label class="cb"><input type="checkbox" checked><span class="cb-box" aria-hidden="true"></span><span>Chat channel</span></label>
<label class="cb"><input type="checkbox" checked><span class="cb-box" aria-hidden="true"></span><span>Email channel</span></label>
<label class="cb"><input type="checkbox"><span class="cb-box" aria-hidden="true"></span><span>SMS channel</span></label>
<label class="cb cb-disabled"><input type="checkbox" disabled><span class="cb-box" aria-hidden="true"></span><span>Voice (Pro)</span></label>

<!-- Radios — one-from-many; share the name attribute. -->
<label class="rb"><input type="radio" name="tone" checked><span class="rb-circle" aria-hidden="true"></span><span>Warm & conversational</span></label>
<label class="rb"><input type="radio" name="tone"><span class="rb-circle" aria-hidden="true"></span><span>Direct & efficient</span></label>
<label class="rb"><input type="radio" name="tone"><span class="rb-circle" aria-hidden="true"></span><span>Playful & curious</span></label>
.cb, .rb {
  display: flex; align-items: center; gap: var(--s-3);
  font: 400 14.5px/1.3 var(--f-body); color: var(--fg);
  cursor: pointer; padding: 6px 0;
  user-select: none;
}
.cb input, .rb input { position: absolute; opacity: 0; pointer-events: none; }
.cb-box, .rb-circle {
  flex: 0 0 18px;
  width: 18px; height: 18px;
  background: var(--bg-paper);
  border: 1.5px solid var(--hair);
  transition: background var(--dur-2) var(--ease),
              border-color var(--dur-2) var(--ease);
  position: relative;
}
.cb-box { border-radius: var(--r-xs); }
.rb-circle { border-radius: 50%; }

.cb input:checked + .cb-box {
  background: var(--accent); border-color: var(--accent);
}
.cb input:checked + .cb-box::after {
  content: "";
  position: absolute; inset: 0;
  background: none;
  border-right: 2px solid var(--paper);
  border-bottom: 2px solid var(--paper);
  width: 5px; height: 10px;
  margin: auto; top: -2px; left: 0; right: 0; bottom: 0;
  transform: rotate(45deg);
}

.rb input:checked + .rb-circle { border-color: var(--accent); }
.rb input:checked + .rb-circle::after {
  content: "";
  position: absolute; inset: 3px;
  background: var(--accent); border-radius: 50%;
}

.cb input:focus-visible + .cb-box,
.rb input:focus-visible + .rb-circle { box-shadow: var(--sh-focus); }
.cb-disabled { opacity: .5; cursor: not-allowed; }
import { Checkbox, Radio, RadioGroup } from "@magicblocksai/ui";

export default function Demo() {
  return (
    <>
      <Checkbox defaultChecked>Chat channel</Checkbox>
      <Checkbox defaultChecked>Email channel</Checkbox>
      <Checkbox>SMS channel</Checkbox>
      <Checkbox disabled>Voice (Pro)</Checkbox>

      <RadioGroup name="tone" defaultValue="warm">
        <Radio value="warm">Warm & conversational</Radio>
        <Radio value="direct">Direct & efficient</Radio>
        <Radio value="playful">Playful & curious</Radio>
      </RadioGroup>
    </>
  );
}

4.12 Segmented control

Pill-shaped tab strip for switching between sibling views. Use when options are few (≤5) and mutually exclusive.

Segmented

.seg

<!-- 1. Default size — channel switcher -->
<div class="seg" role="tablist">
  <button class="seg-btn is-active">Chat</button>
  <button class="seg-btn">Email</button>
  <button class="seg-btn">SMS</button>
  <button class="seg-btn">Voice</button>
</div>

<!-- 2. Small variant — date range picker -->
<div class="seg seg-sm" role="tablist">
  <button class="seg-btn is-active">Today</button>
  <button class="seg-btn">Week</button>
  <button class="seg-btn">Month</button>
</div>
.seg {
  display: inline-flex;
  /* warm-3 (lightest warm) for the rail — warm-5/bg-sunk reads as
     "dirty/heavy" against white card surfaces. Dark mode flips back. */
  background: var(--warm-3);
  border: 1px solid var(--hair);
  border-radius: var(--r-pill);
  padding: 3px;
  gap: 0;
}
body[data-theme="dark"] .seg { background: var(--bg-sunk); }
.seg-btn {
  appearance: none; border: 0;
  background: transparent;
  color: var(--fg-dim);
  padding: 7px 14px;
  border-radius: var(--r-pill);
  font: 500 13px/1 var(--f-body);
  cursor: pointer;
  transition: color var(--dur-2) var(--ease),
              background var(--dur-2) var(--ease);
}
.seg-btn:hover { color: var(--fg); }
.seg-btn.is-active {
  background: var(--bg-paper);
  color: var(--fg);
  box-shadow: var(--sh-1);
}
.seg-sm .seg-btn { padding: 5px 11px; font-size: 12px; }
import { useState } from "react";
// Segmented ships as a CSS-only pattern in @magicblocksai/css. Wire up the
// active state with whatever React state you prefer.
export default function Demo() {
  const [channel, setChannel] = useState("chat");
  const [range,   setRange]   = useState("today");

  return (
    <>
      {/* 1. Default — channel switcher */}
      <div className="seg" role="tablist">
        {["chat", "email", "sms", "voice"].map((v) => (
          <button
            key={v}
            className={`seg-btn ${channel === v ? "is-active" : ""}`}
            onClick={() => setChannel(v)}
          >{v[0].toUpperCase() + v.slice(1)}</button>
        ))}
      </div>

      {/* 2. Small variant — date range picker */}
      <div className="seg seg-sm" role="tablist">
        {["today", "week", "month"].map((v) => (
          <button
            key={v}
            className={`seg-btn ${range === v ? "is-active" : ""}`}
            onClick={() => setRange(v)}
          >{v[0].toUpperCase() + v.slice(1)}</button>
        ))}
      </div>
    </>
  );
}

4.13 Close & dismiss

A 28×28 pill with just the × glyph. Always paired with aria-label='Close' or 'Dismiss'. Use the soft variant when sitting inside a warning or info surface.

Close button

.close-btn

<!-- 1. Default close — neutral against any surface -->
<button class="close-btn" aria-label="Close">
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>

<!-- 2. Soft close — for warning / info surfaces (pre-coloured pink) -->
<button class="close-btn close-btn-soft" aria-label="Dismiss">
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
.close-btn {
  width: 28px; height: 28px;
  display: inline-flex; align-items: center; justify-content: center;
  background: transparent;
  border: 0;
  color: var(--fg-dim);
  border-radius: var(--r-sm);
  cursor: pointer;
  transition: background var(--dur-2) var(--ease),
              color var(--dur-2) var(--ease);
}
.close-btn:hover { background: var(--bg-sunk); color: var(--fg); }

.close-btn-soft { background: var(--accent-soft); color: var(--accent-text); }
.close-btn-soft:hover { background: var(--accent); color: var(--paper); }
// .close-btn is a 28x28 dismiss specifically; for general icon-only buttons
// use <IconButton> (36x36). The CSS classes ship via @magicblocksai/css.
export default function Demo() {
  const Glyph = () => (
    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25">
      <path d="M18 6 6 18M6 6l12 12" strokeLinecap="round" />
    </svg>
  );
  return (
    <>
      <button className="close-btn" aria-label="Close"><Glyph /></button>
      <button className="close-btn close-btn-soft" aria-label="Dismiss"><Glyph /></button>
    </>
  );
}

4.14 Anatomy of the primary button

Six tokens assemble the hero CTA.

  1. 1
    1. Fill
    background: var(--accent);
  2. 2
    2. Label
    font-family: var(--f-display); font-weight: 600;
  3. 3
    3. Gap
    gap: var(--s-2);
  4. 4
    4. Padding
    padding: 11px var(--s-5);
  5. 5
    5. Radius
    border-radius: var(--r-md);
  6. 6
    6. Elevation
    box-shadow: var(--sh-pink);

4.15 Split button

Primary action paired with a caret button that opens a menu of alternate modes — Send now + Schedule send, Save + Save and publish, Run + Run on a subset. Distinct from the CSS-only .btn-split recipe in section 3.8 — this is the React-component primitive with a built-in popover, four tones, three sizes, and an aria-expanded caret. Use when the alternate modes are first-class siblings of the primary action.

Tones · primary and ghost

.split-button

Two tones at default md size. The caret opens a menu with one label per option, plus an optional caption beneath each label. Pressing the caret toggles .is-open; Escape or outside-click dismiss.

<div class="split-button is-tone-primary is-size-md">
  <button type="button" class="split-button-primary">Send 1,248 SMS</button>
  <button type="button" class="split-button-caret" aria-label="More send options" aria-haspopup="menu" aria-expanded="false">
    <svg width="12" height="12" viewBox="0 0 12 12"><path d="M3 4.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
  </button>
</div>
.split-button {
  display: inline-flex;
  position: relative;
  isolation: isolate;
}

.split-button.is-disabled { opacity: 0.55; pointer-events: none; }

.split-button-primary,
.split-button-caret {
  appearance: none;
  border: 0;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font: 500 14px/1 var(--f-body);
  transition: background var(--dur-2) var(--ease),
              filter var(--dur-2) var(--ease);
}

.split-button-primary {
  padding: 0 var(--s-4);
  border-radius: var(--r-md) 0 0 var(--r-md);
  border-right: 1px solid color-mix(in oklab, currentColor 18%, transparent);
}

.split-button-caret {
  padding: 0 8px;
  border-radius: 0 var(--r-md) var(--r-md) 0;
}

.split-button.is-size-sm .split-button-primary,
.split-button.is-size-sm .split-button-caret { height: 30px; font-size: 13px; }

.split-button.is-size-md .split-button-primary,
.split-button.is-size-md .split-button-caret { height: 36px; }

.split-button.is-size-lg .split-button-primary,
.split-button.is-size-lg .split-button-caret { height: 42px; font-size: 15px; }

.split-button.is-tone-primary .split-button-primary,
.split-button.is-tone-primary .split-button-caret {
  background: var(--accent);
  color: var(--paper);
}

.split-button.is-tone-primary .split-button-primary:hover,
.split-button.is-tone-primary .split-button-caret:hover { filter: brightness(0.95); }

.split-button.is-tone-neutral .split-button-primary,
.split-button.is-tone-neutral .split-button-caret {
  background: var(--ink);
  color: var(--paper);
}

.split-button.is-tone-danger .split-button-primary,
.split-button.is-tone-danger .split-button-caret {
  background: var(--error, #c0392b);
  color: var(--paper);
}

.split-button.is-tone-ghost .split-button-primary,
.split-button.is-tone-ghost .split-button-caret {
  background: var(--bg-paper);
  color: var(--fg);
  border-top: 1px solid var(--hair);
  border-bottom: 1px solid var(--hair);
}

.split-button.is-tone-ghost .split-button-primary { border-left: 1px solid var(--hair); }

.split-button.is-tone-ghost .split-button-caret { border-right: 1px solid var(--hair); }

/* …additional rules trimmed for brevity — see _shared.css */
import { SplitButton } from "@magicblocksai/ui";

<SplitButton
  tone="primary"
  onPrimaryClick={() => sendNow()}
  options={[
    { id: "schedule", label: "Schedule send", caption: "Pick a date and time.", onSelect: openScheduler },
    { id: "test",     label: "Send test SMS", caption: "Use the workspace test number.", onSelect: sendTest },
  ]}
>
  Send 1,248 SMS
</SplitButton>

<SplitButton
  tone="ghost"
  onPrimaryClick={() => save()}
  caretLabel="More save options"
  options={[
    { id: "publish", label: "Save and publish", onSelect: saveAndPublish },
    { id: "copy",    label: "Save as copy",    onSelect: saveAsCopy },
  ]}
>
  Save
</SplitButton>