Chapter 05 · Data entry

Forms & inputs. Where the conversation begins.

Forms are the first real conversation a lead has with the product. They should feel calm, trustworthy, and forgiving.

5.1 Text input

The workhorse. 1px hair border, 6px radius, 14.5px DM Sans. Focus lights up pink with a soft ring. Always paired with a visible label — never rely on placeholder alone.

Label + input + hint

<input class='input'>

<label class="input-wrap">
  <span class="input-label">Work email</span>
  <input class="input" type="email"
         placeholder="[email protected]">
  <span class="input-hint">We'll send a verification link.</span>
</label>
// npm install @magicblocksai/ui @magicblocksai/css
import { Input, Label } from "@magicblocksai/ui";

export default function Demo() {
  return (
    <div className="flex flex-col gap-1.5 max-w-[320px]">
      <Label htmlFor="email" hint="We'll send a verification link.">
        Work email
      </Label>
      <Input id="email" type="email"
             placeholder="[email protected]" />
    </div>
  );
}
.input-wrap { display: flex; flex-direction: column; gap: 6px; }
.input-label {
  font: 500 13px/1.3 var(--f-body);
  color: var(--fg);
}
.input-hint  { font: 400 12.5px/1.4 var(--f-body); color: var(--fg-dim); }
.input-error { font: 500 12.5px/1.4 var(--f-body); color: var(--error-text); display: inline-flex; align-items: center; gap: 4px; }
.input-success { font: 500 12.5px/1.4 var(--f-body); color: var(--success-text); display: inline-flex; align-items: center; gap: 4px; }

.input {
  width: 100%;
  font: 400 14.5px/1.4 var(--f-body);
  color: var(--fg);
  background: var(--bg-paper);
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
  padding: 10px 14px;
  transition: border-color var(--dur-2) var(--ease),
              box-shadow var(--dur-2) var(--ease);
  appearance: none; -webkit-appearance: none;
}
.input::placeholder { color: var(--fg-faint); }
.input:hover:not(:disabled):not(:focus) { border-color: var(--fg-dim); }
.input:focus {
  outline: 0;
  border-color: var(--accent);
  box-shadow: var(--sh-focus);
}
.input:disabled { background: var(--bg-sunk); color: var(--fg-dim); cursor: not-allowed; }

.input-wrap.is-error .input { border-color: var(--error); }
.input-wrap.is-error .input:focus { box-shadow: 0 0 0 3px var(--error-soft); }
.input-wrap.is-success .input { border-color: var(--success); }

5.2 Icons, prefixes, suffixes

Leading icons clarify intent (mail, search). Trailing buttons affect the input (show password, clear). Prefix/suffix chips handle URLs, currency, units.

Input group

.input-group

<!-- 1. Leading icon (search) -->
<label class="input-wrap">
  <span class="input-label">Search</span>
  <div class="input-group">
    <span class="input-affix"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg></span>
    <input class="input has-leading" type="search" placeholder="Find a lead, agent, or metric…">
  </div>
</label>

<!-- 2. Leading icon (email) -->
<label class="input-wrap">
  <span class="input-label">Work email</span>
  <div class="input-group">
    <span class="input-affix"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg></span>
    <input class="input has-leading" type="email" value="[email protected]">
  </div>
</label>

<!-- 3. Trailing button (show password eye) -->
<label class="input-wrap">
  <span class="input-label">Password</span>
  <div class="input-group">
    <input class="input has-trailing" type="password" value="••••••••••">
    <button class="input-affix input-affix-btn" type="button" aria-label="Show password"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg></button>
  </div>
</label>

<!-- 4. Prefix + suffix chips (URL builder) -->
<label class="input-wrap">
  <span class="input-label">Domain</span>
  <div class="input-group">
    <span class="input-prefix">https://</span>
    <input class="input has-prefix" type="text" value="magicblocks.ai">
    <span class="input-suffix">/agents</span>
  </div>
</label>
.input-group {
  position: relative;
  display: flex;
  background: var(--bg-paper);
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
}
.input-group:focus-within {
  border-color: var(--accent);
  box-shadow: var(--sh-focus);
}
.input-group .input { border: 0; background: transparent; }
.input-group .input:focus { box-shadow: none; }
.input-affix {
  position: absolute; top: 0; bottom: 0;
  display: inline-flex; align-items: center; justify-content: center;
  width: 38px; color: var(--fg-dim);
  pointer-events: none;
}
.input-group .input-affix:first-child { left: 0; }
.input-group .input-affix:last-child  { right: 0; }
.input-affix-btn { pointer-events: auto; background: transparent; border: 0; cursor: pointer; }
.input.has-leading  { padding-left: 38px; }
.input.has-trailing { padding-right: 38px; }

.input-prefix, .input-suffix {
  display: inline-flex; align-items: center;
  padding: 0 12px;
  font: 400 14px/1 var(--f-mono);
  color: var(--fg-dim);
  background: var(--bg-sunk);
  border-right: 1px solid var(--hair);
}
.input-suffix { border-right: 0; border-left: 1px solid var(--hair); }
.input.has-prefix { padding-left: 12px; }
import { Input, Label } from "@magicblocksai/ui";

export default function Demo() {
  return (
    <>
      {/* 1. Leading search icon */}
      <Input type="search" leadingIcon={<SearchIcon />}
        placeholder="Find a lead, agent, or metric…" />

      {/* 2. Leading email icon */}
      <Input type="email" leadingIcon={<MailIcon />}
        defaultValue="[email protected]" />

      {/* 3. Trailing show-password button */}
      <Input type="password"
        trailingButton={{ label: "Show password", icon: <EyeIcon />, onClick: togglePw }}
        defaultValue="••••••••••" />

      {/* 4. Prefix + suffix chips */}
      <Input prefix="https://" suffix="/agents" defaultValue="magicblocks.ai" />
    </>
  );
}

5.3 States

Every input moves through six states. Error wins all ties — if a field is both error and focused, show the error. Success is confirmation only, not a default.

Default · focus · success · error · disabled · read-only

Six states

<!-- 1. Default — empty, ready to type -->
<label class="input-wrap">
  <span class="input-label">Default</span>
  <input class="input" type="text" placeholder="Empty, ready to type">
</label>

<!-- 2. Focus — accent border + focus ring -->
<label class="input-wrap">
  <span class="input-label">Focus</span>
  <input class="input is-focus-demo" type="text" value="In progress…">
</label>

<!-- 3. Success — wrap takes .is-success -->
<label class="input-wrap is-success">
  <span class="input-label">Success</span>
  <input class="input" type="email" value="[email protected]">
  <span class="input-success"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6 9 17l-5-5"/></svg> Verified</span>
</label>

<!-- 4. Error — wrap takes .is-error; show inline message -->
<label class="input-wrap is-error">
  <span class="input-label">Error</span>
  <input class="input" type="email" value="not-an-email">
  <span class="input-error"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/></svg> Enter a valid work email.</span>
</label>

<!-- 5. Disabled -->
<label class="input-wrap">
  <span class="input-label">Disabled</span>
  <input class="input" type="text" value="Workspace owner only" disabled>
</label>

<!-- 6. Read-only — warm-3 wash -->
<label class="input-wrap">
  <span class="input-label">Read-only</span>
  <input class="input is-readonly" type="text" value="wk_78f2bc91…" readonly>
</label>
.input-wrap.is-error .input { border-color: var(--error); }
.input-wrap.is-error .input:focus { box-shadow: 0 0 0 3px var(--error-soft); }
.input-wrap.is-success .input { border-color: var(--success); }

.input.is-focus-demo { border-color: var(--accent); box-shadow: var(--sh-focus); }
.input.is-readonly   { background: var(--warm-3); color: var(--fg-soft); }
body[data-theme="dark"] .input.is-readonly { background: var(--bg-sunk); }
import { Input } from "@magicblocksai/ui";

export default function Demo() {
  return (
    <>
      {/* 1. Default */}
      <Input placeholder="Empty, ready to type" />

      {/* 2. Focus — autoFocus to demo, real focus is just :focus */}
      <Input autoFocus defaultValue="In progress…" />

      {/* 3. Success */}
      <Input tone="success" message="Verified"
        defaultValue="[email protected]" />

      {/* 4. Error */}
      <Input tone="error" message="Enter a valid work email."
        defaultValue="not-an-email" />

      {/* 5. Disabled */}
      <Input disabled defaultValue="Workspace owner only" />

      {/* 6. Read-only */}
      <Input readOnly defaultValue="wk_78f2bc91…" />
    </>
  );
}

5.4 Textarea

Resize vertical only. Pair with a character counter on the trailing side when there's a real limit.

Textarea

.textarea

<label class="input-wrap">
  <span class="input-label">System prompt for your agent</span>
  <textarea class="input textarea" rows="5"
    placeholder="Tell the agent who it is, who it's talking to, and what a successful conversation looks like…"></textarea>
  <div class="input-meta">
    <span class="input-hint">Markdown supported.</span>
    <span class="input-counter mono">0 / 2,000</span>
  </div>
</label>
.textarea { resize: vertical; min-height: 96px; line-height: 1.55; }
.input-meta { display: flex; justify-content: space-between; align-items: center; }
.input-counter { font-size: 12px; color: var(--fg-dim); }

.input-wrap.is-error .input { border-color: var(--error); }
.input-wrap.is-error .input:focus { box-shadow: 0 0 0 3px var(--error-soft); }
.input-wrap.is-success .input { border-color: var(--success); }

.textarea {
  resize: vertical;
  min-height: 96px;
  line-height: 1.55;
  font-family: var(--f-body);
}
.input-meta {
  display: flex; justify-content: space-between; align-items: center;
  gap: var(--s-4);
}
.input-counter { font-size: 12px; color: var(--fg-dim); }
import { Textarea, Label } from "@magicblocksai/ui";

export default function Demo() {
  return (
    <div className="flex flex-col gap-1.5 max-w-[460px]">
      <Label htmlFor="prompt" hint="Markdown supported.">
        System prompt for your agent
      </Label>
      <Textarea
        id="prompt"
        rows={5}
        maxLength={2000}
        placeholder="Tell the agent who it is…"
      />
    </div>
  );
}

5.5 Select

Native <select> styled to match. Keeps accessibility + mobile keyboards intact, wins every tradeoff.

Styled native select

.select-wrap > select

<label class="input-wrap">
  <span class="input-label">Channel</span>
  <div class="select-wrap">
    <select class="input select">
      <option>Chat</option>
      <option>Email</option>
      <option>SMS</option>
      <option>Voice</option>
    </select>
    <span class="select-chevron" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="m6 9 6 6 6-6"/></svg></span>
  </div>
</label>

<!-- Time zone variant — long option labels -->
<label class="input-wrap">
  <span class="input-label">Time zone</span>
  <div class="select-wrap">
    <select class="input select">
      <option>Australia / Sydney (UTC+10)</option>
      <option>US / Pacific (UTC-8)</option>
      <option>Europe / London (UTC+0)</option>
    </select>
    <span class="select-chevron" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="m6 9 6 6 6-6"/></svg></span>
  </div>
</label>
.select-wrap { position: relative; display: block; }
.select {
  padding-right: 44px;
  cursor: pointer;
  appearance: none; -webkit-appearance: none; -moz-appearance: none;
}
.select-chevron {
  position: absolute; right: 14px; top: 50%;
  transform: translateY(-50%);
  width: 16px; height: 16px;
  color: var(--fg-dim); pointer-events: none;
}
.select-wrap:focus-within .select-chevron { color: var(--accent-text); }

/* fall back to .input rules below for the field shell */
.input-wrap { display: flex; flex-direction: column; gap: 6px; }
.input-label {
  font: 500 13px/1.3 var(--f-body);
  color: var(--fg);
}
.input-hint  { font: 400 12.5px/1.4 var(--f-body); color: var(--fg-dim); }
.input-error { font: 500 12.5px/1.4 var(--f-body); color: var(--error-text); display: inline-flex; align-items: center; gap: 4px; }
.input-success { font: 500 12.5px/1.4 var(--f-body); color: var(--success-text); display: inline-flex; align-items: center; gap: 4px; }

.input {
  width: 100%;
  font: 400 14.5px/1.4 var(--f-body);
  color: var(--fg);
  background: var(--bg-paper);
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
  padding: 10px 14px;
  transition: border-color var(--dur-2) var(--ease),
              box-shadow var(--dur-2) var(--ease);
  appearance: none; -webkit-appearance: none;
}
.input::placeholder { color: var(--fg-faint); }
.input:hover:not(:disabled):not(:focus) { border-color: var(--fg-dim); }
.input:focus {
  outline: 0;
  border-color: var(--accent);
  box-shadow: var(--sh-focus);
}
.input:disabled { background: var(--bg-sunk); color: var(--fg-dim); cursor: not-allowed; }

.input-wrap.is-error .input { border-color: var(--error); }
.input-wrap.is-error .input:focus { box-shadow: 0 0 0 3px var(--error-soft); }
.input-wrap.is-success .input { border-color: var(--success); }

.select-wrap { position: relative; display: block; }
.select {
  padding-right: 44px;
  cursor: pointer;
  appearance: none; -webkit-appearance: none; -moz-appearance: none;
  background-image: none;
  min-width: 0;
  text-overflow: ellipsis;
}
.select::-moz-focus-inner { border: 0; }
.select-chevron {
  position: absolute; right: 14px; top: 50%;
  transform: translateY(-50%);
  width: 16px; height: 16px;
  color: var(--fg-dim); pointer-events: none;
  transition: color var(--dur-2) var(--ease);
  display: inline-flex;
}
.select-wrap:focus-within .select-chevron { color: var(--accent-text); }
import { Select, Label } from "@magicblocksai/ui";

export default function Demo() {
  return (
    <div className="flex flex-col gap-1.5 max-w-[340px]">
      <Label htmlFor="channel">Channel</Label>
      <Select id="channel" defaultValue="chat">
        <option value="chat">Chat</option>
        <option value="email">Email</option>
        <option value="sms">SMS</option>
        <option value="voice">Voice</option>
      </Select>
    </div>
  );
}

5.7 File upload & dropzone

A warm dropzone with dashed hair border. Uploaded files collapse into a chip showing icon + name + size + remove.

Dropzone + file chip

.dropzone · .file-chip

📄
product-playbook.pdf
2.4 MB · uploaded
<!-- 1. Empty dropzone -->
<label class="dropzone">
  <div class="dropzone-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg></div>
  <div class="dropzone-title">Drop your knowledge base</div>
  <div class="dropzone-hint">PDF, DOCX, MD · up to 50MB · or <span>browse</span></div>
  <input type="file" hidden>
</label>

<!-- 2. Uploaded file chip -->
<div class="file-chip">
  <div class="file-chip-icon">📄</div>
  <div class="file-chip-meta">
    <div class="file-chip-name">product-playbook.pdf</div>
    <div class="file-chip-size">2.4 MB · uploaded</div>
  </div>
  <button class="file-chip-x" aria-label="Remove">×</button>
</div>
// .dropzone and .file-chip ship as CSS-only patterns. Wire up native file
// input behaviour or DnD with whatever React state shape you prefer.
export default function Demo({ uploaded }: { uploaded?: File }) {
  return (
    <>
      <label className="dropzone">
        <UploadIcon className="dropzone-icon" />
        <div className="dropzone-title">Drop your knowledge base</div>
        <div className="dropzone-hint">PDF, DOCX, MD · up to 50MB · or <span>browse</span></div>
        <input type="file" hidden />
      </label>

      {uploaded && (
        <div className="file-chip">
          <div className="file-chip-icon">📄</div>
          <div className="file-chip-meta">
            <div className="file-chip-name">{uploaded.name}</div>
            <div className="file-chip-size">{(uploaded.size / 1e6).toFixed(1)} MB</div>
          </div>
          <button className="file-chip-x" aria-label="Remove">×</button>
        </div>
      )}
    </>
  );
}
.dropzone {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: var(--s-2);
  padding: var(--s-9) var(--s-7);
  background: var(--bg-paper);
  border: 1.5px dashed var(--hair);
  border-radius: var(--r-lg);
  color: var(--fg);
  cursor: pointer;
  text-align: center;
  min-width: 320px; max-width: 460px;
  transition: border-color var(--dur-2) var(--ease), background var(--dur-2) var(--ease);
}
.dropzone:hover { border-color: var(--accent); background: color-mix(in oklab, var(--accent) 4%, var(--bg-paper)); }
.dropzone-icon { color: var(--accent-text); margin-bottom: var(--s-1); }
.dropzone-title { font: 600 16px/1.3 var(--f-display); }
.dropzone-hint { font: 400 13px/1.4 var(--f-body); color: var(--fg-soft); }
.dropzone-hint span { color: var(--accent-text); text-decoration: underline; text-underline-offset: 3px; }

.file-chip {
  display: inline-flex; align-items: center; gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  background: var(--bg-paper);
  border: 1px solid var(--hair);
  border-radius: var(--r-md);
  min-width: 280px;
}
.file-chip-icon {
  width: 36px; height: 36px; border-radius: var(--r-sm);
  background: var(--accent-soft); color: var(--accent-text);
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 16px;
}
.file-chip-meta { flex: 1; min-width: 0; }
.file-chip-name {
  font: 600 13.5px/1.3 var(--f-body); color: var(--fg);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.file-chip-size { font: 400 11.5px/1.3 var(--f-mono); color: var(--fg-dim); }
.file-chip-x {
  width: 24px; height: 24px;
  background: transparent; border: 0; color: var(--fg-dim);
  border-radius: 50%; cursor: pointer; font-size: 18px;
}
.file-chip-x:hover { background: var(--bg-sunk); color: var(--fg); }

5.8 Fieldset & legend

Group related inputs inside a fieldset. Use this for settings panels, multi-choice blocks, and any time a cluster of controls shares a context.

Fieldset + legend + description

<fieldset>

Notification preferences

Pick where we should reach you about lead activity.

<fieldset class="fs">
  <legend class="fs-legend">Notification preferences</legend>
  <p class="fs-desc">How would you like to hear about new replies?</p>
  <div class="fs-body">
    <label class="rb"><input type="radio" name="notif" checked><span class="rb-circle"></span><span>Realtime push</span></label>
    <label class="rb"><input type="radio" name="notif"><span class="rb-circle"></span><span>Daily digest</span></label>
    <label class="rb"><input type="radio" name="notif"><span class="rb-circle"></span><span>Weekly recap</span></label>
  </div>
</fieldset>
import { Radio, RadioGroup } from "@magicblocksai/ui";
// .fs / .fs-legend / .fs-desc / .fs-body ship as CSS-only chrome.
export default function Demo() {
  return (
    <fieldset className="fs">
      <legend className="fs-legend">Notification preferences</legend>
      <p className="fs-desc">How would you like to hear about new replies?</p>
      <RadioGroup name="notif" defaultValue="realtime" className="fs-body">
        <Radio value="realtime">Realtime push</Radio>
        <Radio value="daily">Daily digest</Radio>
        <Radio value="weekly">Weekly recap</Radio>
      </RadioGroup>
    </fieldset>
  );
}
.fs {
  border: 1px solid var(--hair);
  border-radius: var(--r-lg);
  padding: var(--s-6);
  background: var(--bg-paper);
  display: flex; flex-direction: column; gap: var(--s-3);
}
.fs-legend {
  font: 600 15px/1.3 var(--f-display);
  color: var(--fg);
  padding: 0 var(--s-2);
  margin-left: -2px;
}
.fs-desc { font: 400 13px/1.5 var(--f-body); color: var(--fg-soft); margin: 0 0 var(--s-3); }

.cb { display: flex; align-items: center; gap: var(--s-3); cursor: pointer; font: 400 14px/1.3 var(--f-body); color: var(--fg); padding: 4px 0; }
.cb input { position: absolute; opacity: 0; pointer-events: none; }
.cb-box { flex: 0 0 18px; width: 18px; height: 18px; background: var(--bg-paper); border: 1.5px solid var(--hair); border-radius: var(--r-xs); position: relative; transition: background var(--dur-2) var(--ease), border-color var(--dur-2) var(--ease); }
.cb input:checked + .cb-box { background: var(--accent); border-color: var(--accent); }
.cb input:checked + .cb-box::after { content: ""; position: absolute; width: 5px; height: 10px; border-right: 2px solid var(--paper); border-bottom: 2px solid var(--paper); margin: auto; top: -2px; left: 0; right: 0; bottom: 0; transform: rotate(45deg); }

5.9 Combobox

Type-to-filter input with a dropdown list. Use when the option set is large (>7) and known, but users may want to type. Arrow keys navigate, Enter selects, Esc closes.

Filterable combobox

.combo

Input borrows the standard .input styling; the open menu is a paper card with hair border and highlighted :is-active row.

<div class="combo">
  <label class="input-wrap">
    <span class="input-label">Assign to</span>
    <input class="input" role="combobox" aria-expanded="true" />
  </label>
  <ul class="combo-menu" role="listbox">
    <li class="combo-row is-active" role="option">Priya Raman</li>
    <li class="combo-row" role="option">Pradeep Kumar</li>
  </ul>
</div>
.combo { position: relative; }
.combo-menu { list-style: none; margin: var(--s-2) 0 0; padding: var(--s-2);
  background: var(--bg-paper); border: 1px solid var(--hair);
  border-radius: var(--r-md); box-shadow: var(--sh-2); }
.combo-row { display: flex; justify-content: space-between; align-items: center;
  padding: var(--s-2) var(--s-3); border-radius: var(--r-xs);
  font: 400 14px/1 var(--f-body); color: var(--fg); cursor: pointer; }
.combo-row.is-active, .combo-row:hover { background: var(--accent-soft); color: var(--accent-text); }
.combo-meta { font: 400 12px/1 var(--f-mono); color: var(--fg-dim); }
import { useState, useMemo } from "react";
// Combobox is a CSS-only pattern from @magicblocksai/css. Wire keyboard nav
// (↑/↓/Enter/Esc) and filtering with whatever React state you prefer.
const people = [
  { name: "Priya Raman",   email: "[email protected]" },
  { name: "Pradeep Kumar", email: "[email protected]" },
  { name: "Proxima West",  email: "[email protected]" },
];

export default function Demo() {
  const [q, setQ] = useState("Pr");
  const [active, setActive] = useState(0);
  const matches = useMemo(() => people.filter((p) => p.name.startsWith(q)), [q]);

  return (
    <div className="combo">
      <label className="input-wrap">
        <span className="input-label">Assign to</span>
        <input className="input" type="text" value={q}
          role="combobox" aria-expanded={matches.length > 0}
          onChange={(e) => setQ(e.target.value)} />
      </label>
      {matches.length > 0 && (
        <ul className="combo-menu" role="listbox">
          {matches.map((p, i) => (
            <li
              key={p.email}
              role="option"
              aria-selected={i === active}
              className={`combo-row ${i === active ? "is-active" : ""}`}
            >
              <span>{p.name}</span>
              <span className="combo-meta">{p.email}</span>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

5.10 Date picker

Input trigger + popover calendar. Tabular-nums for the day grid; today has a pink ring; selected is a pink-filled cell. Range-selection picks a start and an end.

Date field + calendar

.datepick

The calendar popover uses a 7-column grid. Hover, focus, and keyboard navigation all target individual cells.

April 2026
MTWTFSS
<div class="datepick">
  <label class="input-wrap">
    <span class="input-label">Meeting date</span>
    <input class="input" type="text" aria-haspopup="dialog" />
  </label>
  <div class="cal">
    <header class="cal-head">
      <button class="cal-nav">‹</button>
      <div class="cal-title">April 2026</div>
      <button class="cal-nav">›</button>
    </header>
    <div class="cal-grid">
      <button class="cal-d is-today">21</button>
      <button class="cal-d is-sel">24</button>
    </div>
  </div>
</div>
.cal { margin-top: var(--s-2); padding: var(--s-3);
  background: var(--bg-paper); border: 1px solid var(--hair);
  border-radius: var(--r-md); box-shadow: var(--sh-2); }
.cal-head { display: flex; justify-content: space-between; align-items: center;
  margin-bottom: var(--s-2); }
.cal-title { font: 600 14px/1 var(--f-display); color: var(--fg); }
.cal-nav { width: 28px; height: 28px; border: 0; background: transparent;
  border-radius: var(--r-xs); color: var(--fg-soft); cursor: pointer; }
.cal-nav:hover { background: var(--bg-sunk); color: var(--fg); }
.cal-weekdays, .cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.cal-weekdays span { text-align: center; font: 500 10px/1 var(--f-mono);
  text-transform: uppercase; color: var(--fg-dim); padding: var(--s-2) 0; }
.cal-d { aspect-ratio: 1; border: 0; background: transparent;
  border-radius: var(--r-xs); font: 500 13px/1 var(--f-mono);
  font-variant-numeric: tabular-nums; color: var(--fg); cursor: pointer; }
.cal-d:hover { background: var(--bg-sunk); }
.cal-d.is-out { color: var(--fg-dim); }
.cal-d.is-today { box-shadow: inset 0 0 0 1.5px var(--accent); color: var(--accent-text); }
.cal-d.is-sel { background: var(--accent); color: var(--paper); }
import { useState } from "react";
// .datepick / .cal- styles ship as CSS-only chrome from @magicblocksai/css.
// Wire month navigation and selection with the date library of your choice.
export default function Demo() {
  const [open, setOpen] = useState(true);
  const [value, setValue] = useState("Apr 24, 2026");

  return (
    <div className="datepick">
      <label className="input-wrap">
        <span className="input-label">Meeting date</span>
        <input className="input" type="text" value={value}
          aria-haspopup="dialog" aria-expanded={open}
          onClick={() => setOpen(true)} readOnly />
      </label>
      {open && (
        <div className="cal" role="dialog">
          /* render <Calendar /> or your own grid here */
        </div>
      )}
    </div>
  );
}

5.11 Range sliders

Single- and dual-handle range inputs. Native <input type="range"> under the hood — keyboard arrow nav, screen-reader labels, and mobile touch all work without custom code.

Single-value slider

.range

Pink fill up to the current value; ink-bordered circular thumb. Live value chip on the right of the label. Ticks are optional.

Lead volume / month 1,440
02,000+
<div class="range" style="--pct: 72;">
  <div class="range-head">
    <span class="lbl">Lead volume / month</span>
    <span class="val" data-range-value>1,440</span>
  </div>
  <div class="range-track-wrap">
    <input type="range" min="0" max="2000" step="10" value="1440"
           aria-label="Monthly lead volume" data-range-input>
  </div>
  <div class="range-meta"><span>0</span><span>2,000+</span></div>
</div>

<!-- Tiny listener (add once per page):
     [data-range-input] → updates --pct on the .range wrapper + value chip -->
<script>
  document.addEventListener('input', (e) => {
    const el = e.target.closest('[data-range-input]');
    if (!el) return;
    const r  = el.closest('.range');
    const min = +el.min, max = +el.max, v = +el.value;
    r.style.setProperty('--pct', ((v - min) / (max - min)) * 100);
    const out = r.querySelector('[data-range-value]');
    if (out) out.textContent = Number(v).toLocaleString();
  });
</script>
.range input[type="range"]::-webkit-slider-runnable-track {
  height: 6px; border-radius: 6px;
  background: linear-gradient(to right,
    var(--accent) 0%,
    var(--accent) calc(var(--pct) * 1%),
    var(--hair)   calc(var(--pct) * 1%),
    var(--hair)   100%);
}
.range input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 22px; height: 22px; border-radius: 50%;
  background: var(--bg-paper); border: 2px solid var(--ink);
  margin-top: -8px;
}
.range input[type="range"]:focus-visible::-webkit-slider-thumb {
  border-color: var(--accent);
  box-shadow: 0 0 0 6px var(--accent-soft);
}
import { useState } from "react";
// .range / .range-track-wrap ship as CSS-only chrome. Drive the --pct CSS
// custom property from React state to keep the pink fill in sync.
export default function Demo() {
  const [v, setV] = useState(1440);
  const pct = (v / 2000) * 100;

  return (
    <div className="range" style={{ "--pct": pct } as React.CSSProperties}>
      <div className="range-head">
        <span className="lbl">Lead volume / month</span>
        <span className="val">{v.toLocaleString()}</span>
      </div>
      <div className="range-track-wrap">
        <input type="range" min={0} max={2000} step={10}
          value={v} onChange={(e) => setV(+e.target.value)}
          aria-label="Monthly lead volume" />
      </div>
      <div className="range-meta"><span>0</span><span>2,000+</span></div>
    </div>
  );
}

Stepped slider with tick labels

.range.stepped

Add ticks via <span class="tick" style="--at: 25%"> children under the track.

Follow-up cadence Day 3
0 1 2 3 7

Dual-handle range (min / max)

.range-pair

Two stacked native range inputs; a tiny listener keeps the handles from crossing and drives the filled band.

Deal size range $40k — $140k
$0$200k
<div class="range" style="--min: 20; --max: 70;">
  <div class="range-head">
    <span class="lbl">Deal size range</span>
    <span class="val" data-range-pair-value>$40k — $140k</span>
  </div>
  <div class="range-pair">
    <div class="range-pair-track"></div>
    <input type="range" min="0" max="200" step="10" value="40"  data-range-min>
    <input type="range" min="0" max="200" step="10" value="140" data-range-max>
  </div>
</div>

<script>
  document.addEventListener('input', (e) => {
    const lo = e.target.closest('[data-range-min]');
    const hi = e.target.closest('[data-range-max]');
    if (!lo && !hi) return;
    const r = (lo || hi).closest('.range');
    const a = r.querySelector('[data-range-min]');
    const b = r.querySelector('[data-range-max]');
    let av = +a.value, bv = +b.value;
    if (av > bv) { if (lo) { a.value = bv; av = bv; } else { b.value = av; bv = av; } }
    const min = +a.min, max = +a.max;
    r.style.setProperty('--min', ((av - min) / (max - min)) * 100);
    r.style.setProperty('--max', ((bv - min) / (max - min)) * 100);
    const out = r.querySelector('[data-range-pair-value]');
    if (out) out.textContent = '$' + av + 'k — $' + bv + 'k';
  });
</script>
.range {
  display: flex; flex-direction: column; gap: var(--s-2);
  width: 100%; max-width: 360px;
}

.range-head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--s-3);
}

.range-head .lbl {
  font: 500 13px/1.3 var(--f-body); color: var(--fg);
}

.range-head .val {
  font: 500 13px/1 var(--f-mono); color: var(--accent-text);
  font-variant-numeric: tabular-nums;
  padding: 3px 10px; border-radius: 999px;
  background: var(--accent-soft);
}

.range-track-wrap { position: relative; height: 28px; display: flex; align-items: center; }

.range input[type="range"] {
  -webkit-appearance: none; appearance: none;
  width: 100%; height: 6px; margin: 0;
  background: transparent;
  cursor: grab;
}

.range input[type="range"]:active { cursor: grabbing; }

.range input[type="range"]::-webkit-slider-runnable-track {
  height: 6px; border-radius: 6px;
  background: linear-gradient(
    to right,
    var(--accent) 0%,
    var(--accent) calc(var(--pct, 50) * 1%),
    var(--hair)   calc(var(--pct, 50) * 1%),
    var(--hair)   100%
  );
}

.range input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none;
  width: 22px; height: 22px; border-radius: 50%;
  background: var(--paper);          /* always white — dark-mode safe */
  border: 2px solid var(--fg);       /* flips to warm in dark for contrast */
  margin-top: -8px;
  box-shadow: var(--sh-1);
  transition: transform var(--dur-1) var(--ease), box-shadow var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease);
}

.range input[type="range"]:focus-visible::-webkit-slider-thumb {
  border-color: var(--accent);
  box-shadow: 0 0 0 6px var(--accent-soft);
  outline: none;
}

.range input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.08); }

.range input[type="range"]::-moz-range-track {
  height: 6px; border-radius: 6px;
  background: var(--hair);
}

.range input[type="range"]::-moz-range-progress {
  height: 6px; border-radius: 6px;
  background: var(--accent);
}

.range input[type="range"]::-moz-range-thumb {
  width: 22px; height: 22px; border-radius: 50%;
  background: var(--paper);          /* always white — dark-mode safe */
  border: 2px solid var(--fg);       /* flips to warm in dark for contrast */
  box-shadow: var(--sh-1);
}

/* …additional rules trimmed for brevity — see _shared.css */
import { useState } from "react";
export default function Demo() {
  const [lo, setLo] = useState(40);
  const [hi, setHi] = useState(140);
  const pct = (n: number) => (n / 200) * 100;

  return (
    <div
      className="range"
      style={{ "--min": pct(lo), "--max": pct(hi) } as React.CSSProperties}
    >
      <div className="range-head">
        <span className="lbl">Deal size range</span>
        <span className="val">${lo}k — ${hi}k</span>
      </div>
      <div className="range-pair">
        <div className="range-pair-track" />
        <input type="range" min={0} max={200} step={10}
          value={lo} onChange={(e) => setLo(Math.min(+e.target.value, hi))}
          aria-label="Minimum deal size" />
        <input type="range" min={0} max={200} step={10}
          value={hi} onChange={(e) => setHi(Math.max(+e.target.value, lo))}
          aria-label="Maximum deal size" />
      </div>
      <div className="range-meta"><span>$0</span><span>$200k</span></div>
    </div>
  );
}

5.12 Form layout

Stack labels above inputs. Two-column rows only for inputs that share a mental category (first/last, city/zip). Actions live at the bottom-right.

A complete form

<form>

Use grid for two-column rows. Actions row is bottom-right with ghost cancel + primary submit.

<form class="form-demo">
  <!-- Two-column row for first / last (related fields) -->
  <div class="form-row">
    <label class="input-wrap"><span class="input-label">First name</span><input class="input" value="Jay"></label>
    <label class="input-wrap"><span class="input-label">Last name</span><input class="input" value="Stockwell"></label>
  </div>

  <!-- Single column for everything else -->
  <label class="input-wrap">
    <span class="input-label">Work email</span>
    <input class="input" type="email" value="[email protected]">
  </label>
  <label class="input-wrap">
    <span class="input-label">What are you trying to solve?</span>
    <textarea class="input textarea" rows="3">Our team drops 40% of inbound leads…</textarea>
  </label>

  <!-- Action row — bottom-right, ghost cancel + primary submit -->
  <div class="form-actions">
    <button class="btn btn-ghost" type="button">Cancel</button>
    <button class="btn btn-primary" type="submit">Book a demo</button>
  </div>
</form>
.input-wrap { display: flex; flex-direction: column; gap: 6px; }
.input-label {
  font: 500 13px/1.3 var(--f-body);
  color: var(--fg);
}
.input-hint  { font: 400 12.5px/1.4 var(--f-body); color: var(--fg-dim); }
.input-error { font: 500 12.5px/1.4 var(--f-body); color: var(--error-text); display: inline-flex; align-items: center; gap: 4px; }
.input-success { font: 500 12.5px/1.4 var(--f-body); color: var(--success-text); display: inline-flex; align-items: center; gap: 4px; }

.input {
  width: 100%;
  font: 400 14.5px/1.4 var(--f-body);
  color: var(--fg);
  background: var(--bg-paper);
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
  padding: 10px 14px;
  transition: border-color var(--dur-2) var(--ease),
              box-shadow var(--dur-2) var(--ease);
  appearance: none; -webkit-appearance: none;
}
.input::placeholder { color: var(--fg-faint); }
.input:hover:not(:disabled):not(:focus) { border-color: var(--fg-dim); }
.input:focus {
  outline: 0;
  border-color: var(--accent);
  box-shadow: var(--sh-focus);
}
.input:disabled { background: var(--bg-sunk); color: var(--fg-dim); cursor: not-allowed; }

.input-wrap.is-error .input { border-color: var(--error); }
.input-wrap.is-error .input:focus { box-shadow: 0 0 0 3px var(--error-soft); }
.input-wrap.is-success .input { border-color: var(--success); }

.form-demo {
  display: flex; flex-direction: column; gap: var(--s-5);
  background: var(--bg-paper);
  padding: var(--s-7);
  border: 1px solid var(--hair);
  border-radius: var(--r-lg);
  max-width: 520px; width: 100%;
}
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-4); }
@media (max-width: 560px) { .form-row { grid-template-columns: 1fr; } }
.form-actions { display: flex; justify-content: flex-end; gap: var(--s-3); margin-top: var(--s-2); }
.btn-ghost-mini, .btn-primary-mini {
  font: 600 14px/1 var(--f-display);
  padding: 10px var(--s-5);
  border-radius: var(--r-md); cursor: pointer; border: 1px solid transparent;
  transition: transform var(--dur-2) var(--ease), background var(--dur-2) var(--ease), border-color var(--dur-2) var(--ease);
}
.btn-ghost-mini { background: transparent; color: var(--fg); }
.btn-ghost-mini:hover { background: var(--bg-sunk); }
.btn-primary-mini { background: var(--accent); color: var(--paper); box-shadow: var(--sh-pink); }
.btn-primary-mini:hover { transform: translateY(-1px); }
import { Button, Input, Label, Textarea } from "@magicblocksai/ui";
import { type FormEventHandler } from "react";

export default function Demo() {
  const onSubmit: FormEventHandler<HTMLFormElement> = (e) => {
    e.preventDefault();
    // handle submit
  };

  return (
    <form onSubmit={onSubmit} className="flex flex-col gap-s-5 bg-bg-paper border border-hair rounded-lg p-s-7 max-w-[520px]">
      <div className="grid grid-cols-1 sm:grid-cols-2 gap-s-4">
        <div className="flex flex-col gap-1.5">
          <Label htmlFor="first">First name</Label>
          <Input id="first" defaultValue="Jay" />
        </div>
        <div className="flex flex-col gap-1.5">
          <Label htmlFor="last">Last name</Label>
          <Input id="last" defaultValue="Stockwell" />
        </div>
      </div>

      <div className="flex flex-col gap-1.5">
        <Label htmlFor="email">Work email</Label>
        <Input id="email" type="email" defaultValue="[email protected]" />
      </div>

      <div className="flex flex-col gap-1.5">
        <Label htmlFor="problem">What are you trying to solve?</Label>
        <Textarea id="problem" rows={3} />
      </div>

      <div className="flex justify-end gap-s-3 mt-s-2">
        <Button type="button" variant="ghost">Cancel</Button>
        <Button type="submit">Book a demo</Button>
      </div>
    </form>
  );
}

5.13 Anatomy of a field

Six tokens define every input in the library.

  1. 1
    1. Label
    font: 500 13px/1.3 var(--f-body);
  2. 2
    2. Field
    padding: 10px 14px; border-radius: var(--r-sm);
  3. 3
    3. Border
    border: 1px solid var(--hair);
  4. 4
    4. Hint
    font: 400 12.5px/1.4 var(--f-body); color: var(--fg-dim);
  5. 5
    5. Gap
    gap: 6px;
  6. 6
    6. Focus ring
    box-shadow: var(--sh-focus);

5.14 Slider

Numeric slider with a paired value readout — the kit’s standard “creativity”, “temperature”, “weight” control. Wraps a native <input type="range"> so keyboard arrow nav, screen-reader labels, and mobile touch all work without custom code.

Slider

.slider
0.7
<div class="slider">
  <div class="slider-head">
    <label class="slider-label" for="creativity">Creativity</label>
    <span class="slider-value">0.7</span>
  </div>
  <input id="creativity" type="range" class="slider-input"
         min="0" max="1" step="0.05" value="0.7">
</div>
.slider { display: flex; flex-direction: column; gap: 6px; }

.slider.is-disabled { opacity: 0.5; pointer-events: none; }

.slider-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-3);
}

.slider-label { font: 500 13px/1.3 var(--f-body); color: var(--fg); }

.slider-value {
  font: 500 12.5px/1 var(--f-mono);
  color: var(--fg-soft);
  font-variant-numeric: tabular-nums;
}

.slider-input {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  height: 4px;
  border-radius: 999px;
  background: var(--bg-warm);
  outline: none;
  margin: 6px 0;
}

.slider-input:focus-visible {
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 35%, transparent);
}

.slider-input::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 16px;
  height: 16px;
  border-radius: 999px;
  background: var(--accent);
  border: 2px solid var(--paper);
  box-shadow: 0 1px 3px color-mix(in oklab, var(--ink) 25%, transparent);
  cursor: pointer;
  transition: transform var(--dur-2) var(--ease);
}

.slider-input::-moz-range-thumb {
  width: 16px;
  height: 16px;
  border-radius: 999px;
  background: var(--accent);
  border: 2px solid var(--paper);
  cursor: pointer;
}

.slider-input:hover::-webkit-slider-thumb,
.slider-input:focus::-webkit-slider-thumb { transform: scale(1.1); }

@media (prefers-reduced-motion: reduce) {
  .slider-input::-webkit-slider-thumb { transition: none; }
}
import { Slider } from "@magicblocksai/ui";

<Slider
  id="creativity"
  label="Creativity"
  defaultValue={0.7}
  min={0}
  max={1}
  step={0.05}
/>

5.15 DurationField

Composite count + unit duration input — the kit’s standard “wait for…” editor. Backs the “No message received from user for…” agent condition and any future delay / debounce surface.

DurationField

.duration-field
<div class="duration-field">
  <label class="duration-field-label" for="d">If no message received for</label>
  <div class="duration-field-row">
    <input id="d" type="number" class="duration-field-count"
           inputmode="numeric" min="0" max="999" value="10">
    <select class="duration-field-unit" aria-label="Unit">
      <option>Minutes</option><option>Hours</option>
      <option>Days</option><option>Months</option>
    </select>
  </div>
</div>
.duration-field { display: flex; flex-direction: column; gap: 6px; }

.duration-field.is-disabled { opacity: 0.55; pointer-events: none; }

.duration-field-label { font: 500 13px/1.3 var(--f-body); color: var(--fg); }

.duration-field-row {
  display: grid;
  grid-template-columns: 96px 1fr;
  gap: var(--s-2);
}

.duration-field-count,
.duration-field-unit {
  height: 36px;
  padding: 0 var(--s-3);
  border: 1px solid var(--hair);
  border-radius: var(--r-md);
  background: var(--bg-paper);
  color: var(--fg);
  font: 400 14px/1 var(--f-body);
}

.duration-field-count { text-align: right; font-variant-numeric: tabular-nums; }

.duration-field-count:focus-visible,
.duration-field-unit:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
import { DurationField } from "@magicblocksai/ui";

<DurationField
  label="If no message received for"
  defaultValue={{ count: 10, unit: "minutes" }}
/>

5.16 TimePicker

HH:MM time input. Wraps the native <input type="time"> for keyboard parity and accessibility, with an optional 12-hour caption beside the field. Used in the Availability surface (Work Days start / finish), Schedule-send pickers, and any “from… to…” surface.

TimePicker

.time-picker
<div class="time-picker">
  <label class="time-picker-label" for="t">Start of day</label>
  <div class="time-picker-row">
    <input id="t" type="time" class="time-picker-input"
           value="09:00" step="900" min="06:00" max="22:00">
    <span class="time-picker-12h">09:00 AM</span>
  </div>
</div>
.time-picker { display: flex; flex-direction: column; gap: 6px; }

.time-picker.is-disabled { opacity: 0.55; pointer-events: none; }

.time-picker-label { font: 500 13px/1.3 var(--f-body); color: var(--fg); }

.time-picker-row { display: inline-flex; align-items: center; gap: var(--s-2); }

.time-picker-input {
  height: 32px;
  padding: 0 var(--s-3);
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
  background: var(--bg-paper);
  color: var(--fg);
  font: 400 13px/1 var(--f-mono);
  font-variant-numeric: tabular-nums;
}

.time-picker-input:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.time-picker-12h {
  font: 400 12.5px/1 var(--f-mono);
  color: var(--fg-soft);
  font-variant-numeric: tabular-nums;
}
import { TimePicker } from "@magicblocksai/ui";

<TimePicker
  label="Start of day"
  defaultValue="09:00"
  stepMinutes={15}
  min="06:00"
  max="22:00"
  use12Hour
/>

5.17 DatePicker

Single-date picker — trigger button shows the formatted date; a popover with a single-month calendar opens on click. Sibling to <DateRangePicker>; both share the internal CalendarGrid.

DatePicker

.date-picker

The chapter shows the closed trigger. Clicking opens the calendar popover (kit-rendered, keyboard-navigable, focus-trapped) via the kit’s <Portal>.

<div class="date-picker">
  <button type="button" class="date-picker-trigger input"
          aria-haspopup="dialog" aria-expanded="false">
    <span class="date-picker-placeholder">Pick a date</span>
    <span aria-hidden="true" class="date-picker-trigger-ic">▾</span>
  </button>
  <!-- Calendar popover rendered via Portal when open -->
</div>
.input-label:has(> input, > select, > textarea, > .input-group, > .date-picker, > .combobox, > .multiselect, > .date-range-picker),
.input-label.is-stack {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.date-picker,
.date-range-picker {
  display: inline-flex;
  flex-direction: column;
  font-family: var(--f-body);
}

.date-picker-trigger {
  display: inline-flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  cursor: pointer;
  background: var(--bg-paper);
  text-align: left;
  min-width: 200px;
}

.date-picker-trigger:disabled { cursor: not-allowed; opacity: 0.55; }

.date-picker-trigger-ic {
  font-size: 10px;
  color: var(--fg-faint);
}

.date-picker-placeholder { color: var(--fg-faint); }

.date-picker-popover {
  padding: var(--s-3);
  min-width: 280px;
}
import { useState } from "react";
import { DatePicker } from "@magicblocksai/ui";

function Example() {
  const [date, setDate] = useState<Date | null>(null);
  return <DatePicker value={date} onChange={setDate} />;
}

5.18 DateRangePicker

Date-range picker with a preset rail + two-month calendar. Sibling to <DatePicker>; both share the internal CalendarGrid.

DateRangePicker

.date-range-picker

Closed trigger renders inline; the preset rail + dual calendars open via <Portal> on click.

<div class="date-range-picker">
  <button type="button" class="date-picker-trigger input"
          aria-haspopup="dialog" aria-expanded="false">
    <span class="date-picker-placeholder">Select range</span>
    <span aria-hidden="true" class="date-picker-trigger-ic">▾</span>
  </button>
  <!-- Preset rail + two-month calendar rendered via Portal -->
</div>
.input-label:has(> input, > select, > textarea, > .input-group, > .date-picker, > .combobox, > .multiselect, > .date-range-picker),
.input-label.is-stack {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.date-picker,
.date-range-picker {
  display: inline-flex;
  flex-direction: column;
  font-family: var(--f-body);
}
import { useState } from "react";
import { DateRangePicker } from "@magicblocksai/ui";
import type { DateRange } from "@magicblocksai/ui";

function Example() {
  const [range, setRange] = useState<DateRange>({ from: null, to: null });
  return <DateRangePicker value={range} onChange={setRange} />;
}

5.19 Combobox (kit)

Searchable single-select. Trigger borrows the <Select> chrome; click opens a popover (via <Portal>) with a search input + result list. Supports static options or an async search(query) resolver.

Combobox

.combobox

Closed-state trigger only; the search + result list open in a portal-mounted popover.

<div class="combobox">
  <button type="button" class="combobox-trigger input"
          aria-haspopup="listbox" aria-expanded="false">
    <span class="combobox-trigger-label combobox-trigger-placeholder">
      Assign owner
    </span>
    <span class="combobox-trigger-actions">
      <span class="combobox-trigger-ic">▾</span>
    </span>
  </button>
  <!-- Search input + result list rendered via Portal -->
</div>
:is(html, body)[data-theme="dark"] .modal,
:is(html, body)[data-theme="dark"] .drawer,
:is(html, body)[data-theme="dark"] .popover,
:is(html, body)[data-theme="dark"] .menu,
:is(html, body)[data-theme="dark"] .cmdk,
:is(html, body)[data-theme="dark"] .combobox-popover,
:is(html, body)[data-theme="dark"] .nav-chapters-panel {
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.05),
    var(--sh-3, 0 10px 28px rgba(0, 0, 0, 0.35), 0 2px 4px rgba(0, 0, 0, 0.25));
}

.input-label:has(> input, > select, > textarea, > .input-group, > .date-picker, > .combobox, > .multiselect, > .date-range-picker),
.input-label.is-stack {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.combobox { display: inline-flex; flex-direction: column; min-width: 220px; }

.combobox-trigger {
  display: inline-flex; align-items: center; justify-content: space-between;
  gap: var(--s-3);
  cursor: pointer;
  background: var(--bg-paper);
  text-align: left;
  width: 100%;
}

.combobox-trigger:disabled { cursor: not-allowed; opacity: 0.55; }

.combobox-trigger-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.combobox-trigger-placeholder { color: var(--fg-faint); }

.combobox-trigger-actions { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; }

.combobox-trigger-clear {
  appearance: none; background: transparent; border: 0; cursor: pointer;
  width: 18px; height: 18px; border-radius: var(--r-xs);
  color: var(--fg-faint); display: inline-flex; align-items: center; justify-content: center;
  padding: 0;
}

.combobox-trigger-clear:hover { color: var(--fg); background: var(--warm-3); }

.combobox-trigger-ic { font-size: 10px; color: var(--fg-faint); }

.combobox-popover {
  width: 320px;
  padding: 0;
  display: flex; flex-direction: column;
  max-height: 320px;
}

.combobox-search {
  padding: var(--s-3);
  border-bottom: 1px solid var(--hair);
}

.combobox-search .input { width: 100%; }

.combobox-list {
  list-style: none;
  margin: 0; padding: var(--s-2);
  display: flex; flex-direction: column; gap: 2px;
  overflow-y: auto;
  flex: 1 1 auto;
  min-height: 0;
}

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

type Owner = { id: string; name: string };

const owners: Owner[] = [
  { id: "u1", name: "Jay" },
  { id: "u2", name: "Sam" },
];

function Example() {
  const [owner, setOwner] = useState<Owner | null>(null);
  return (
    <Combobox
      value={owner}
      onChange={setOwner}
      options={owners}
      getLabel={(o) => o.name}
      getKey={(o) => o.id}
      placeholder="Assign owner"
      clearable
    />
  );
}

5.20 MultiSelect

Multi-value selection with chip-rendered selections and a keyboard-navigable popover. Backspace at the empty input removes the last chip. The popover stays open by default — multi-pick is iterative.

MultiSelect

.multi-select

Closed-state control with one selected chip + empty input. The dropdown opens via <Portal>.

Design
<div class="multi-select">
  <div class="multi-select-control">
    <span class="multi-select-chip">
      <span class="multi-select-chip-label">Design</span>
      <button class="multi-select-chip-remove" aria-label="Remove Design">×</button>
    </span>
    <input class="multi-select-input" placeholder="Pick tags…">
    <span class="multi-select-chevron">▾</span>
  </div>
</div>
.multi-select {
  position: relative;
  font: inherit;
}

.multi-select-control {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 4px;
  min-height: 38px;
  padding: 4px 8px 4px 6px;
  background: var(--bg-paper);
  border: 1px solid var(--hair);
  border-radius: var(--r-md);
  cursor: text;
  transition: border-color var(--dur-1) var(--ease), box-shadow var(--dur-1) var(--ease);
}

.multi-select.is-open .multi-select-control,
.multi-select-control:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }

.multi-select.is-disabled .multi-select-control { opacity: 0.55; cursor: not-allowed; }

.multi-select-chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 2px 4px 2px 8px;
  background: var(--accent-soft);
  color: var(--accent-text);
  border-radius: var(--r-sm);
  font: 500 12.5px/1.4 var(--f-body);
  white-space: nowrap;
  max-width: 100%;
}

.multi-select-chip-overflow {
  background: var(--bg-warm);
  color: var(--fg-dim);
  padding: 2px 8px;
}

.multi-select-chip-label { overflow: hidden; text-overflow: ellipsis; }

.multi-select-chip-remove {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  border: 0;
  background: transparent;
  color: inherit;
  opacity: 0.7;
  border-radius: var(--r-xs);
  cursor: pointer;
  transition: background var(--dur-1) var(--ease), opacity var(--dur-1) var(--ease);
}

.multi-select-chip-remove:hover { opacity: 1; background: rgba(0, 0, 0, 0.06); }

.multi-select-chip-remove svg { width: 12px; height: 12px; }

.multi-select-input {
  flex: 1;
  min-width: 80px;
  border: 0;
  background: transparent;
  outline: none;
  padding: 4px 4px;
  font: inherit;
  color: var(--fg);
}

.multi-select-input::placeholder { color: var(--fg-faint); }

.multi-select-chevron {
  display: inline-flex;
  align-items: center;
  color: var(--fg-dim);
  margin-left: auto;
  transition: transform var(--dur-2) var(--ease);
}

.multi-select-chevron svg { width: 12px; height: 12px; }

.multi-select.is-open .multi-select-chevron { transform: rotate(180deg); }

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

function Example() {
  const [tags, setTags] = useState<string[]>(["design"]);
  return (
    <MultiSelect
      value={tags}
      onChange={setTags}
      options={[
        { value: "design",    label: "Design" },
        { value: "eng",       label: "Engineering" },
        { value: "support",   label: "Support" },
      ]}
      placeholder="Pick tags…"
    />
  );
}

5.21 MentionInput

Async @-mention textarea. Type the trigger character (default @) to open a popover with items resolved by your search(query) callback. Arrow keys navigate, Enter / Tab insert, Escape dismisses.

MentionInput

.mention-input

Resting state: just the textarea. The picker appears when the trigger character is typed.

<div class="mention-input">
  <textarea class="input textarea" rows="3"
            placeholder="Leave a note… use @ to mention a teammate"></textarea>
  <!-- Picker (.mention-picker) appears below when @ is typed -->
</div>
.mention-input {
  position: relative;
}
import { useState } from "react";
import { MentionInput } from "@magicblocksai/ui";

function Example() {
  const [body, setBody] = useState("");
  return (
    <MentionInput
      value={body}
      onChange={setBody}
      rows={3}
      placeholder="Leave a note… use @ to mention a teammate"
      search={async (q) => (await fetch(`/api/users?q=${q}`)).json()}
    />
  );
}

5.22 MergeTagInput

Single-line input and multiline textarea with {{ }} merge-tag autocomplete. Wraps the kit’s .input chrome with a portal-mounted suggestion popover.

MergeTagInput

.merge-tag-field

Resting state: just the input. The tag picker opens via <Portal> when the user types {{.

<div class="merge-tag-field">
  <input class="input" type="text"
         placeholder="Hi {{contact.first_name}} —">
  <!-- Suggestion popover rendered via Portal when {{ is typed -->
</div>
.merge-tag-field {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  position: relative;
}
import { useState } from "react";
import { MergeTagInput } from "@magicblocksai/ui";
import type { MergeTag } from "@magicblocksai/ui";

const tags: MergeTag[] = [
  { tag: "{{contact.first_name}}", label: "First name", sample: "Jay" },
  { tag: "{{contact.email}}",      label: "Email",      sample: "[email protected]" },
];

function Example() {
  const [body, setBody] = useState("Hi {{contact.first_name}} —");
  return <MergeTagInput value={body} onChange={setBody} tags={tags} />;
}

5.23 SnippetTextarea

Textarea + always-visible Insert snippet picker — the canonical text input for every agent message field, persona prompt, custom-fallback message, and action body in the Next Gen app.

SnippetTextarea

.snippet-textarea
Inserted snippets are resolved at send time.
<div class="snippet-textarea">
  <div class="snippet-textarea-head">
    <label class="snippet-textarea-label" for="t">Welcome message</label>
    <span class="snippet-textarea-caption">
      Inserted snippets are resolved at send time.
    </span>
  </div>
  <div class="snippet-textarea-toolbar">
    <button type="button" class="snippet-textarea-trigger"
            aria-haspopup="menu" aria-expanded="false">
      <span class="snippet-textarea-trigger-glyph">{+}</span>
      <span class="snippet-textarea-trigger-label">Insert snippet</span>
    </button>
  </div>
  <div class="snippet-textarea-input-wrap">
    <textarea id="t" class="snippet-textarea-input" rows="5">
Hi {{contact.first_name}} —</textarea>
  </div>
</div>
.snippet-textarea { display: flex; flex-direction: column; gap: 6px; }

.snippet-textarea.is-disabled { opacity: 0.55; pointer-events: none; }

.snippet-textarea-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-3);
}

.snippet-textarea-label { font: 500 13px/1.3 var(--f-body); color: var(--fg); }

.snippet-textarea-caption { font: 400 12px/1.4 var(--f-body); color: var(--fg-soft); }

.snippet-textarea-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
}

.snippet-textarea-trigger {
  appearance: none;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 8px;
  border: 1px solid var(--hair);
  background: var(--bg-paper);
  border-radius: var(--r-sm);
  font: 500 12.5px/1 var(--f-body);
  color: var(--fg);
  cursor: pointer;
  transition: background var(--dur-2) var(--ease);
}

.snippet-textarea-trigger:hover:not(:disabled) { background: var(--bg-warm); }

.snippet-textarea-trigger:disabled { opacity: 0.5; cursor: not-allowed; }

.snippet-textarea-trigger-glyph {
  font: 600 12px/1 var(--f-mono);
  color: var(--accent);
}

.snippet-textarea-trigger-label { line-height: 1; }

.snippet-textarea-meta { font: 400 12px/1 var(--f-body); color: var(--fg-soft); }

.snippet-textarea-input-wrap { position: relative; }

.snippet-textarea-input {
  width: 100%;
  padding: var(--s-3);
  border: 1px solid var(--hair);
  border-radius: var(--r-md);
  background: var(--bg-paper);
  color: var(--fg);
  font: 400 14px/1.5 var(--f-body);
  resize: vertical;
  min-height: 64px;
}

.snippet-textarea-input:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.snippet-textarea-menu {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  right: 0;
  z-index: 50;
  max-height: 280px;
  overflow-y: auto;
  background: var(--bg-paper);
  border: 1px solid var(--hair);
  border-radius: var(--r-md);
  box-shadow: 0 8px 24px color-mix(in oklab, var(--ink) 14%, transparent);
  padding: 4px;
}

.snippet-textarea-group { display: flex; flex-direction: column; }

.snippet-textarea-group + .snippet-textarea-group { margin-top: 4px; }

.snippet-textarea-group-name {
  font: 600 10.5px/1 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--fg-faint);
  padding: 6px 10px 4px;
}

/* …additional rules trimmed for brevity — see _shared.css */
import { useState } from "react";
import { SnippetTextarea } from "@magicblocksai/ui";
import type { Snippet } from "@magicblocksai/ui";

const snippets: Snippet[] = [
  { token: "{{contact.first_name}}", label: "First name", group: "Contact" },
  { token: "{{contact.email}}",      label: "Email",      group: "Contact" },
];

function Example() {
  const [body, setBody] = useState("Hi {{contact.first_name}} —");
  return (
    <SnippetTextarea
      label="Welcome message"
      caption="Inserted snippets are resolved at send time."
      value={body}
      onValueChange={setBody}
      snippets={snippets}
      rows={5}
    />
  );
}

5.24 RichTextEditor

A TipTap-shaped rich text editor with a sanitised contenteditable engine. Toolbar exposes bold / italic / link / list / image / attachments, with optional {{ }} merge-tag autocomplete.

RichTextEditor

.rich-text-editor

Live editor renders a role="toolbar" bar above a contenteditable region; link + tag popovers open via <Portal>.

Write a message…
<div class="rich-text-editor">
  <div role="toolbar" aria-label="Formatting" class="rte-toolbar">
    <button class="icon-btn rte-toolbar-btn" aria-label="Bold">B</button>
    <button class="icon-btn rte-toolbar-btn" aria-label="Italic">I</button>
    <!-- …more toolbar buttons -->
  </div>
  <div class="rte-content" contenteditable="true" role="textbox">
    Write a message…
  </div>
</div>
.rich-text-editor {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--hair);
  border-radius: var(--r-md);
  background: var(--paper);
  position: relative;
  min-height: 160px;
}

.rich-text-editor:focus-within {
  border-color: var(--accent);
  box-shadow: var(--sh-focus);
}

.rich-text-editor-disabled {
  opacity: 0.6;
  pointer-events: none;
}
import { useState } from "react";
import { RichTextEditor } from "@magicblocksai/ui";

function Example() {
  const [html, setHtml] = useState("");
  return (
    <RichTextEditor
      value={html}
      onChange={setHtml}
      placeholder="Write a message…"
    />
  );
}

5.26 FileUpload

File upload with drag-and-drop. Click the zone (or press Enter / Space) to open the native picker; drag files onto it to drop directly. Files are validated against accept, maxSize, and maxFiles before being passed to onFiles.

FileUpload

.file-upload

Empty drop zone. Files are listed below as either thumbnails or rows once uploaded.

Choose files or drag and drop Accepts image/*,.pdf · Up to 5 MB per file
<div class="file-upload">
  <div role="button" tabindex="0" class="dropzone">
    <span class="dropzone-headline">
      <span class="dropzone-cta">Choose files</span> or drag and drop
    </span>
    <span class="dropzone-hint">Accepts image/*,.pdf · Up to 5 MB per file</span>
    <input type="file" class="dropzone-input" accept="image/*,.pdf" multiple>
  </div>
</div>
.file-upload { display: flex; flex-direction: column; gap: var(--s-3); }

.file-upload-list {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column; gap: 4px;
}

.file-upload-row {
  display: grid;
  grid-template-columns: 1fr auto auto;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-2) var(--s-3);
  background: var(--bg-paper);
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
  font: 400 13px/1.3 var(--f-body);
}

.file-upload-row-name {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  color: var(--fg);
}

.file-upload-row-size {
  font: 400 12px/1.3 var(--f-mono);
  color: var(--fg-dim);
}

.file-upload-row-remove {
  appearance: none; background: transparent; border: 0; cursor: pointer;
  color: var(--fg-faint);
  padding: 4px;
  border-radius: var(--r-xs);
}

.file-upload-row-remove:hover { color: var(--error-text); background: var(--warm-3); }

.file-upload-row-progress {
  grid-column: 1 / -1;
  height: 2px; background: var(--warm-3); border-radius: 999px; overflow: hidden;
}

.file-upload-row-progress-fill {
  height: 100%; background: var(--accent); border-radius: 999px;
  transition: width var(--dur-3) var(--ease);
}

.file-upload-thumbnails {
  display: grid; gap: var(--s-3);
  grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
  list-style: none; margin: 0; padding: 0;
}

.file-upload-thumbnail {
  position: relative;
  aspect-ratio: 1;
  border-radius: var(--r-sm);
  background: var(--bg-sunken);
  border: 1px solid var(--hair);
  overflow: hidden;
  display: flex; align-items: center; justify-content: center;
}

.file-upload-thumbnail img {
  width: 100%; height: 100%;
  object-fit: cover;
}

.file-upload-thumbnail-fallback {
  font: 500 11px/1.3 var(--f-mono);
  color: var(--fg-dim);
  padding: var(--s-2);
  text-align: center;
  word-break: break-word;
}

.file-upload-thumbnail-remove {
  position: absolute; top: 4px; right: 4px;
  width: 22px; height: 22px;
  border-radius: 50%;
  background: color-mix(in oklab, var(--ink) 55%, transparent);
  color: var(--paper);
  border: 0; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
  font: 600 12px/1 var(--f-mono);
}

.file-upload-thumbnail-remove:hover { background: var(--ink); }

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

<FileUpload
  accept="image/*,.pdf"
  maxSize={5 * 1024 * 1024}
  multiple
  preview="thumbnails"
  onFiles={(files) => upload(files)}
/>

5.27 InlineDateField

Inline edit-in-place date field. Click the resting label → swap to a native <input type="date">; Enter / blur commits, Escape cancels. Use this for rail-style detail-page fields (Status, Cycle, Owner…), and <DatePicker> when the picker UX is the focus.

InlineDateField

.inline-date-field
<div class="inline-date-field">
  <button type="button" class="inline-date-field-display">
    <span class="inline-date-field-placeholder">Set a date</span>
  </button>
</div>
.inline-date-field {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  width: 100%;
  font: inherit;
}

.inline-date-field-display {
  flex: 1;
  min-width: 0;
  appearance: none;
  background: transparent;
  border: 1px solid transparent;
  padding: 4px 8px;
  border-radius: var(--r-sm);
  text-align: left;
  font: inherit;
  cursor: pointer;
  color: var(--fg);
  transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease);
}

.inline-date-field-display:hover {
  background: var(--bg-warm);
  border-color: var(--hair);
}

.inline-date-field-display:focus-visible {
  outline: 0;
  box-shadow: var(--sh-focus);
  border-color: var(--accent);
}

.inline-date-field-display:disabled {
  cursor: not-allowed;
  opacity: 0.55;
}

.inline-date-field-display[data-tone="warning"] { color: var(--warning-text); }

.inline-date-field-display[data-tone="error"]   { color: var(--error-text); font-weight: 500; }

.inline-date-field-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

.inline-date-field-placeholder { color: var(--fg-faint); }

.inline-date-field-input {
  width: 100%;
  padding: 6px 8px;
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
  font: inherit;
  color: var(--fg);
  background: var(--bg-paper);
  transition: border-color var(--dur-1) var(--ease), box-shadow var(--dur-1) var(--ease);
}

.inline-date-field-input:focus {
  outline: 0;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}

.inline-date-field-clear {
  appearance: none;
  background: transparent;
  border: 0;
  padding: 4px 6px;
  border-radius: var(--r-sm);
  cursor: pointer;
  color: var(--fg-faint);
  font: 500 14px/1 var(--f-body);
  transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease);
  flex-shrink: 0;
}

.inline-date-field-clear:hover { background: var(--bg-warm); color: var(--fg); }

.inline-date-field-clear:focus-visible {
  outline: 0;
  box-shadow: var(--sh-focus);
}
import { useState } from "react";
import { InlineDateField } from "@magicblocksai/ui";

function Example() {
  const [due, setDue] = useState<number | null>(null);
  return <InlineDateField value={due} onChange={setDue} />;
}

5.28 SortableList & SortableHandle

HTML5 drag-and-drop reorder primitive paired with a touch-safe grab handle. <SortableList> renders a <ul> of items, <SortableHandle> adds the right-hand grip (visible on touch, hover-reveal on mouse) plus optional secondary actions.

SortableList composed with SortableHandle

.sortable-list · .sortable-handle

Three reorderable rows. Each row uses <SortableHandle> for the grip; the kit owns the drop-indicator overlay.

  • Alpha⋮⋮
  • Beta⋮⋮
  • Gamma⋮⋮
<ul class="sortable-list">
  <li>
    <div class="step-row">
      <span class="step-row-title">Alpha</span>
      <span class="sortable-handle">
        <span class="sortable-handle-grip" aria-label="Drag to reorder">⋮⋮</span>
      </span>
    </div>
  </li>
  <!-- …more rows -->
</ul>
.sortable-list { position: relative; }

.sortable-handle {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  position: relative;
  /* Default: hover-reveal on mouse; visible at rest on touch (rule below). */
  opacity: 0;
  transition: opacity var(--dur-2, 160ms) var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1));
}

@media (pointer: fine) {
  *:hover > .sortable-handle,
  *:focus-within > .sortable-handle,
  .sortable-handle:focus-within,
  .sortable-handle:hover { opacity: 1; }
}

@media (pointer: coarse) {
  .sortable-handle { opacity: 1; }
}

.sortable-handle.is-always-visible { opacity: 1; }

.sortable-handle.is-menu-open { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .sortable-handle { transition: none; }
}

.sortable-handle-grip {
  appearance: none;
  background: transparent;
  border: 0;
  padding: 4px;
  border-radius: var(--r-xs);
  color: var(--fg-faint);
  cursor: grab;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease);
}

.sortable-handle-grip:active { cursor: grabbing; }

.sortable-handle-grip:hover  { background: var(--bg-warm); color: var(--fg); }

.sortable-handle-grip:focus-visible { outline: 0; box-shadow: var(--sh-focus); }

.sortable-handle-grip svg { width: 12px; height: 12px; }

.sortable-handle-actions {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  position: relative;
}

.sortable-handle-action {
  appearance: none;
  background: transparent;
  border: 0;
  padding: 4px;
  border-radius: var(--r-xs);
  color: var(--fg-dim);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease);
}

.sortable-handle-action:hover { background: var(--bg-warm); color: var(--fg); }

.sortable-handle-action:focus-visible { outline: 0; box-shadow: var(--sh-focus); }

.sortable-handle-action svg { width: 12px; height: 12px; }

.sortable-handle-action-trigger svg { width: 14px; height: 14px; }

@media (pointer: coarse) {
  .sortable-handle-grip,
  .sortable-handle-action {
    padding: 6px;
    min-width: 32px;
    min-height: 32px;
  }
}

/* …additional rules trimmed for brevity — see _shared.css */
import { useState } from "react";
import { SortableList, SortableHandle } from "@magicblocksai/ui";

type Step = { id: string; title: string };

function Example() {
  const [steps, setSteps] = useState<Step[]>([
    { id: "a", title: "Alpha" },
    { id: "b", title: "Beta"  },
    { id: "c", title: "Gamma" },
  ]);
  return (
    <SortableList
      items={steps}
      rowId={(s) => s.id}
      onReorder={setSteps}
      renderRow={(s) => (
        <div className="step-row">
          <span className="step-row-title">{s.title}</span>
          <SortableHandle id={s.id} />
        </div>
      )}
    />
  );
}

5.29 KeyValueRepeater

List of key/value pairs with add + remove affordances — the canonical shape for MCP auth headers, webhook headers, HTTP function parameters, and any “add another header” config form.

KeyValueRepeater

.kv-repeater
Auth headers
<div class="kv-repeater">
  <div class="kv-repeater-label">Auth headers</div>
  <div class="kv-repeater-rows">
    <div class="kv-repeater-row">
      <input class="kv-repeater-key"   placeholder="Header" value="Authorization">
      <input class="kv-repeater-value" placeholder="Value"  value="Bearer …">
      <button class="kv-repeater-remove" aria-label="Remove row">×</button>
    </div>
  </div>
  <button class="kv-repeater-add">+ Add header</button>
</div>
.kv-repeater { display: flex; flex-direction: column; gap: var(--s-2); }

.kv-repeater-label { font: 500 13px/1.3 var(--f-body); color: var(--fg); }

.kv-repeater-rows { display: flex; flex-direction: column; gap: 6px; }

.kv-repeater-row {
  display: grid;
  grid-template-columns: 1fr 1fr 32px;
  gap: 6px;
}

.kv-repeater.is-readonly .kv-repeater-row { grid-template-columns: 1fr 1fr; }

.kv-repeater-key,
.kv-repeater-value {
  height: 32px;
  padding: 0 var(--s-3);
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
  background: var(--bg-paper);
  color: var(--fg);
  font: 400 13px/1 var(--f-body);
}

.kv-repeater-key:focus-visible,
.kv-repeater-value:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.kv-repeater-remove {
  appearance: none;
  background: transparent;
  border: 0;
  width: 32px;
  height: 32px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--fg-faint);
  cursor: pointer;
  border-radius: var(--r-xs);
  transition: color var(--dur-2) var(--ease), background var(--dur-2) var(--ease);
}

.kv-repeater-remove:hover { color: var(--fg); background: var(--bg-warm); }

.kv-repeater-add {
  align-self: flex-start;
  appearance: none;
  background: transparent;
  border: 1px dashed var(--hair);
  border-radius: var(--r-sm);
  padding: 6px 10px;
  font: 500 12.5px/1 var(--f-body);
  color: var(--accent);
  cursor: pointer;
  transition: background var(--dur-2) var(--ease);
}

.kv-repeater-add:hover { background: var(--bg-warm); }
import { useState } from "react";
import { KeyValueRepeater } from "@magicblocksai/ui";
import type { KeyValueRow } from "@magicblocksai/ui";

function Example() {
  const [headers, setHeaders] = useState<KeyValueRow[]>([
    { id: "h-1", key: "Authorization", value: "Bearer …" },
  ]);
  return (
    <KeyValueRepeater
      label="Auth headers"
      value={headers}
      onValueChange={setHeaders}
      keyPlaceholder="Header"
      valuePlaceholder="Value"
      addLabel="+ Add header"
    />
  );
}

5.30 MultiBindingList

Repeater of select + remove rows — every row binds a single value chosen from a shared options pool. Used for the Brain → MCP picker, Knowledge Base Collections multi-select, multi-recipient email destinations, and any “+ Add another” single-binding pattern.

MultiBindingList

.multi-binding-list
MCP servers
<div class="multi-binding-list">
  <div class="multi-binding-list-label">MCP servers</div>
  <div class="multi-binding-list-rows">
    <div class="multi-binding-list-row">
      <select class="multi-binding-list-select" aria-label="Server">…</select>
      <button class="multi-binding-list-remove" aria-label="Remove row">×</button>
    </div>
  </div>
  <button class="multi-binding-list-add">+ Add MCP server</button>
</div>
.multi-binding-list { display: flex; flex-direction: column; gap: var(--s-2); }

.multi-binding-list.is-disabled { opacity: 0.55; pointer-events: none; }

.multi-binding-list-label { font: 500 13px/1.3 var(--f-body); color: var(--fg); }

.multi-binding-list-rows { display: flex; flex-direction: column; gap: 6px; }

.multi-binding-list-row {
  display: grid;
  grid-template-columns: 1fr 28px;
  gap: 6px;
  align-items: center;
}

.multi-binding-list-select {
  height: 32px;
  padding: 0 var(--s-3);
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
  background: var(--bg-paper);
  color: var(--fg);
  font: 400 13px/1 var(--f-body);
}

.multi-binding-list-select:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.multi-binding-list-remove {
  appearance: none;
  background: transparent;
  border: 0;
  width: 28px; height: 28px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--fg-faint);
  cursor: pointer;
  border-radius: var(--r-xs);
}

.multi-binding-list-remove:hover { color: var(--fg); background: var(--bg-warm); }

.multi-binding-list-add {
  align-self: flex-start;
  appearance: none;
  background: transparent;
  border: 1px dashed var(--hair);
  border-radius: var(--r-sm);
  padding: 6px 10px;
  font: 500 12.5px/1 var(--f-body);
  color: var(--accent);
  cursor: pointer;
}

.multi-binding-list-add:hover { background: var(--bg-warm); }

.multi-binding-list-add:disabled { opacity: 0.5; cursor: not-allowed; }
import { useState } from "react";
import { MultiBindingList } from "@magicblocksai/ui";
import type { MultiBinding, MultiBindingOption } from "@magicblocksai/ui";

const options: MultiBindingOption[] = [
  { value: "gcal",     label: "Google Calendar (MCP)" },
  { value: "calcom",   label: "Cal.com" },
  { value: "calendly", label: "Calendly" },
];

function Example() {
  const [rows, setRows] = useState<MultiBinding[]>([]);
  return (
    <MultiBindingList
      label="MCP servers"
      options={options}
      value={rows}
      onValueChange={setRows}
      placeholder="Pick a connector…"
      addLabel="+ Add MCP server"
    />
  );
}

5.31 VariantsRepeater

List of textarea variants with add + remove. Used for AI Message Versions (randomised by the agent runtime), Snippet Variants tabs, and any other “give me three ways to say this” surface.

VariantsRepeater

.variants-repeater
AI message versions The agent randomises across these at send time.
<div class="variants-repeater">
  <div class="variants-repeater-head">
    <span class="variants-repeater-label">AI message versions</span>
    <span class="variants-repeater-caption">
      The agent randomises across these at send time.
    </span>
  </div>
  <div class="variants-repeater-rows">
    <div class="variants-repeater-row">
      <span class="variants-repeater-row-index">1</span>
      <textarea class="variants-repeater-input" rows="2">…</textarea>
      <button class="variants-repeater-remove">×</button>
    </div>
    <!-- …more rows -->
  </div>
  <button class="variants-repeater-add">+ Add variant</button>
</div>
.variants-repeater { display: flex; flex-direction: column; gap: var(--s-2); }

.variants-repeater.is-disabled { opacity: 0.55; pointer-events: none; }

.variants-repeater-head { display: flex; flex-direction: column; gap: 2px; }

.variants-repeater-label { font: 500 13px/1.3 var(--f-body); color: var(--fg); }

.variants-repeater-caption { font: 400 12px/1.4 var(--f-body); color: var(--fg-soft); }

.variants-repeater-rows { display: flex; flex-direction: column; gap: 6px; }

.variants-repeater-row {
  display: grid;
  grid-template-columns: 24px 1fr 28px;
  gap: 6px;
  align-items: flex-start;
}

.variants-repeater-row-index {
  font: 600 11px/1 var(--f-mono);
  color: var(--fg-faint);
  padding-top: 10px;
  text-align: right;
}

.variants-repeater-input {
  padding: var(--s-2) var(--s-3);
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
  background: var(--bg-paper);
  color: var(--fg);
  font: 400 13px/1.5 var(--f-body);
  resize: vertical;
  min-height: 48px;
}

.variants-repeater-input:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.variants-repeater-remove {
  appearance: none;
  background: transparent;
  border: 0;
  width: 28px; height: 28px;
  margin-top: 4px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--fg-faint);
  cursor: pointer;
  border-radius: var(--r-xs);
}

.variants-repeater-remove:hover { color: var(--fg); background: var(--bg-warm); }

.variants-repeater-add {
  align-self: flex-start;
  appearance: none;
  background: transparent;
  border: 1px dashed var(--hair);
  border-radius: var(--r-sm);
  padding: 6px 10px;
  font: 500 12.5px/1 var(--f-body);
  color: var(--accent);
  cursor: pointer;
}

.variants-repeater-add:hover { background: var(--bg-warm); }

.variants-repeater-add:disabled { opacity: 0.5; cursor: not-allowed; }
import { useState } from "react";
import { VariantsRepeater } from "@magicblocksai/ui";
import type { MessageVariant } from "@magicblocksai/ui";

function Example() {
  const [variants, setVariants] = useState<MessageVariant[]>([
    { id: "v1", text: "Hi! How can I help today?" },
    { id: "v2", text: "Hey there — what brings you in?" },
  ]);
  return (
    <VariantsRepeater
      label="AI message versions"
      caption="The agent randomises across these at send time."
      value={variants}
      onValueChange={setVariants}
      rows={2}
    />
  );
}

5.32 RadioCardList

Vertical radio list rendered as cards — title + description + optional reveal sub-form. The kit’s standard “pick one option from a few, each with explanation” shape. Pass layout="grid" + columns for a responsive tile-picker variant (tone presets, agent-type / model-tier choosers).

RadioCardList

.radio-card-list
<div class="radio-card-list" role="radiogroup">
  <div class="radio-card is-selected">
    <label class="radio-card-label">
      <input type="radio" name="first-action" class="radio-card-input" checked>
      <span class="radio-card-radio"></span>
      <span class="radio-card-body">
        <span class="radio-card-title">Send message</span>
        <span class="radio-card-description">Greet the contact…</span>
      </span>
    </label>
  </div>
  <!-- …more cards -->
</div>
.radio-card-list { display: flex; flex-direction: column; gap: var(--s-2); }

.radio-card-list.is-disabled { opacity: 0.55; pointer-events: none; }

.radio-card {
  border: 1px solid var(--hair);
  border-radius: var(--r-md);
  background: var(--bg-paper);
  transition: border-color var(--dur-2) var(--ease),
              background var(--dur-2) var(--ease);
}

.radio-card.is-on {
  border-color: var(--accent);
  background: color-mix(in oklab, var(--accent) 5%, var(--bg-paper));
}

.radio-card.is-disabled { opacity: 0.55; }

.radio-card-label {
  display: grid;
  grid-template-columns: auto auto 1fr auto;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  cursor: pointer;
}

.radio-card.is-disabled .radio-card-label { cursor: not-allowed; }

.radio-card-input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
  width: 0; height: 0;
}

.radio-card-radio {
  width: 16px;
  height: 16px;
  border-radius: 999px;
  border: 1.5px solid var(--hair);
  background: var(--bg-paper);
  flex-shrink: 0;
  position: relative;
  transition: border-color var(--dur-2) var(--ease);
}

.radio-card.is-on .radio-card-radio {
  border-color: var(--accent);
}

.radio-card.is-on .radio-card-radio::after {
  content: "";
  position: absolute;
  inset: 3px;
  border-radius: 999px;
  background: var(--accent);
}

.radio-card-input:focus-visible + .radio-card-radio {
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 35%, transparent);
}

.radio-card-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--fg-soft);
  width: 24px;
  height: 24px;
}

.radio-card-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }

.radio-card-title { font: 500 14px/1.3 var(--f-body); color: var(--fg); }

.radio-card-description { font: 400 13px/1.4 var(--f-body); color: var(--fg-soft); }

.radio-card-meta {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
}

.radio-card-reveal {
  padding: 0 var(--s-4) var(--s-3) calc(var(--s-3) + 24px);
  border-top: 1px solid var(--hair-soft);
  margin-top: -2px;
  padding-top: var(--s-3);
}
import { useState } from "react";
import { RadioCardList } from "@magicblocksai/ui";
import type { RadioCardItem } from "@magicblocksai/ui";

const items: RadioCardItem[] = [
  { id: "send-message", label: "Send message",
    description: "Greet the contact with a custom message when the block fires." },
  { id: "run-task",     label: "Run task",
    description: "Trigger a Library task before responding." },
  { id: "do-nothing",   label: "Do nothing",
    description: "Wait silently for the user to say something first." },
];

function Example() {
  const [pick, setPick] = useState<string | null>("send-message");
  return <RadioCardList items={items} value={pick} onValueChange={setPick} />;
}

RadioCardList — grid

.radio-card-list.is-grid

Tile-picker variant — layout="grid" columns={4}. The whole card is the target; the selected tile gets the accent border + tint (the radio dot is hidden). Collapses to one column on narrow viewports. Resolves the app team’s OptionCardGrid (#23) request.

<div class="radio-card-list is-grid" role="radiogroup" aria-label="Voice" style="--rcl-cols: 4">
  <div class="radio-card is-on">
    <label class="radio-card-label">
      <input type="radio" name="voice" class="radio-card-input" checked>
      <span class="radio-card-radio"></span>
      <span class="radio-card-body">
        <span class="radio-card-title">Warm guide</span>
        <span class="radio-card-description">Friendly, reassuring.</span>
      </span>
    </label>
  </div>
  <!-- …more tiles… -->
</div>
.radio-card-list.is-grid {
  display: grid;
  grid-template-columns: repeat(var(--rcl-cols, 2), minmax(0, 1fr));
  gap: var(--s-4);
}
.radio-card-list.is-grid .radio-card-radio { display: none; }
import { RadioCardList } from "@magicblocksai/ui";

<RadioCardList
  layout="grid"
  columns={4}
  aria-label="Voice"
  value={tone}
  onValueChange={setTone}
  items={[
    { id: "warm", label: "Warm guide", description: "Friendly, reassuring." },
    { id: "direct", label: "Direct expert", description: "No fluff." },
  ]}
/>

5.33 DomainList

Chip-input for hostnames — used for the Design & Go Live domain whitelist, CORS origin lists, and any “list of hosts with validation” surface. Enter, ,, or Space commits the current input as a chip.

DomainList

.domain-list
The widget will only render on these hostnames.
acme.com shop.acme.com
<div class="domain-list">
  <div class="domain-list-head">
    <label class="domain-list-label">Allowed domains</label>
    <span class="domain-list-caption">
      The widget will only render on these hostnames.
    </span>
  </div>
  <div class="domain-list-chips">
    <span class="domain-list-chip">
      <span class="domain-list-chip-label">acme.com</span>
      <button class="domain-list-chip-remove" aria-label="Remove">×</button>
    </span>
    <!-- …more chips -->
    <input class="domain-list-input" placeholder="e.g. example.com">
  </div>
</div>
.domain-list { display: flex; flex-direction: column; gap: var(--s-2); }

.domain-list.is-disabled { opacity: 0.55; pointer-events: none; }

.domain-list-head { display: flex; flex-direction: column; gap: 2px; }

.domain-list-label { font: 500 13px/1.3 var(--f-body); color: var(--fg); }

.domain-list-caption { font: 400 12px/1.4 var(--f-body); color: var(--fg-soft); }

.domain-list-all-toggle {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font: 400 13px/1.3 var(--f-body);
  color: var(--fg);
  cursor: pointer;
}

.domain-list-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  align-items: center;
  padding: 4px 6px;
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
  background: var(--bg-paper);
  min-height: 36px;
}

.domain-list-chips:focus-within {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 35%, transparent);
}

.domain-list-chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 3px 4px 3px 8px;
  background: var(--bg-warm);
  border-radius: 999px;
  font: 400 12.5px/1 var(--f-mono);
  color: var(--fg);
}

.domain-list-chip-label { line-height: 1.2; }

.domain-list-chip-remove {
  appearance: none;
  background: transparent;
  border: 0;
  width: 16px; height: 16px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--fg-faint);
  cursor: pointer;
  border-radius: 999px;
}

.domain-list-chip-remove:hover { color: var(--fg); background: var(--bg-paper); }

.domain-list-input {
  flex: 1;
  min-width: 80px;
  appearance: none;
  background: transparent;
  border: 0;
  outline: none;
  padding: 4px 6px;
  font: 400 13px/1.2 var(--f-body);
  color: var(--fg);
}

.domain-list-error {
  font: 400 12px/1.3 var(--f-body);
  color: var(--error-text, #8B2417);
}
import { useState } from "react";
import { DomainList } from "@magicblocksai/ui";

function Example() {
  const [domains, setDomains] = useState<string[]>(["acme.com", "shop.acme.com"]);
  return (
    <DomainList
      label="Allowed domains"
      caption="The widget will only render on these hostnames."
      value={domains}
      onValueChange={setDomains}
      placeholder="e.g. example.com"
    />
  );
}

5.34 CategoryGroupList

Grouped expandable list — each group has a header (label + count + meta) and a body of items revealed when expanded. Backs the Contact Memories surface, Knowledge categories rail, and any “label + count + expand to see items” surface.

CategoryGroupList

.category-group-list
<div class="category-group-list">
  <div class="category-group">
    <button class="category-group-trigger" aria-expanded="false">
      <span class="category-group-chevron"></span>
      <span class="category-group-title-block">
        <span class="category-group-label">Personal facts</span>
      </span>
      <span class="category-group-count">4</span>
    </button>
    <!-- expanded body in .category-group-panel -->
  </div>
  <!-- …more groups -->
</div>
.category-group-list { display: flex; flex-direction: column; gap: var(--s-2); }

.category-group {
  border: 1px solid var(--hair);
  border-radius: var(--r-md);
  background: var(--bg-paper);
  overflow: hidden;
}

.category-group.is-disabled { opacity: 0.55; pointer-events: none; }

.category-group-trigger {
  appearance: none;
  display: grid;
  grid-template-columns: auto auto 1fr auto auto;
  align-items: center;
  gap: var(--s-3);
  width: 100%;
  padding: var(--s-3) var(--s-4);
  background: transparent;
  border: 0;
  text-align: left;
  cursor: pointer;
  color: var(--fg);
  transition: background var(--dur-2) var(--ease);
}

.category-group-trigger:hover { background: var(--bg-warm); }

.category-group-trigger:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

.category-group-chevron {
  width: 10px; height: 10px;
  flex-shrink: 0;
  position: relative;
  transition: transform var(--dur-2) var(--ease);
}

.category-group-chevron::before {
  content: "";
  position: absolute;
  inset: 0;
  border-right: 1.5px solid currentColor;
  border-bottom: 1.5px solid currentColor;
  transform: translate(-1px, -1px) rotate(-45deg);
}

.category-group.is-open .category-group-chevron { transform: rotate(90deg); }

@media (prefers-reduced-motion: reduce) {
  .category-group-chevron { transition: none; }
}

.category-group-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--fg-soft);
  width: 20px;
  height: 20px;
}

.category-group-title-block { display: flex; flex-direction: column; gap: 2px; min-width: 0; }

.category-group-label { font: 500 14px/1.3 var(--f-body); }

.category-group-caption { font: 400 12.5px/1.4 var(--f-body); color: var(--fg-soft); }

.category-group-count {
  font: 500 12px/1 var(--f-mono);
  color: var(--fg-faint);
  font-variant-numeric: tabular-nums;
}

.category-group-meta { display: inline-flex; align-items: center; gap: var(--s-2); }

.category-group-panel { border-top: 1px solid var(--hair-soft); }

.category-group-panel[hidden] { display: none; }

.category-group-panel-inner { padding: var(--s-3) var(--s-4); }
import { CategoryGroupList } from "@magicblocksai/ui";
import type { CategoryGroup } from "@magicblocksai/ui";

const groups: CategoryGroup[] = [
  { id: "personal", label: "Personal facts", count: 4,
    items: <ul><li>First name: Jay</li></ul> },
  { id: "preferences", label: "Preferences", count: 7,
    items: <ul><li>Prefers email over SMS</li></ul> },
  { id: "objections", label: "Objections", count: 0,
    items: <p>No objections captured yet.</p> },
];

<CategoryGroupList groups={groups} />

5.35 ExpandableEditRow

Collapsed row → expanded inline editor — the kit’s pattern for “click a Knowledge Q&A item to edit it in place”, Missing Knowledge convert flow, Key Fact ledger entries, and any other “list row with reveal-to-edit” surface. Sibling to <Accordion> (card-shaped), this one is row-shaped.

ExpandableEditRow

.expandable-edit-row
<div class="expandable-edit-row">
  <div class="expandable-edit-row-head">
    <button class="expandable-edit-row-trigger" aria-expanded="false">
      <span class="expandable-edit-row-chevron"></span>
      <span class="expandable-edit-row-title">What hours are you open?</span>
      <span class="expandable-edit-row-caption">Asked 12 times this week.</span>
    </button>
  </div>
  <!-- .expandable-edit-row-body when expanded -->
</div>
.expandable-edit-row {
  border: 1px solid var(--hair);
  border-radius: var(--r-md);
  background: var(--bg-paper);
  overflow: hidden;
}

.expandable-edit-row.is-disabled { opacity: 0.55; pointer-events: none; }

.expandable-edit-row-head {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: var(--s-2);
  padding: 6px var(--s-3);
}

.expandable-edit-row-leading {
  display: inline-flex;
  align-items: center;
  color: var(--fg-soft);
}

.expandable-edit-row-trigger {
  appearance: none;
  background: transparent;
  border: 0;
  text-align: left;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 4px;
  border-radius: var(--r-xs);
  cursor: pointer;
  color: var(--fg);
  min-width: 0;
}

.expandable-edit-row-trigger:hover { background: var(--bg-warm); }

.expandable-edit-row-trigger:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.expandable-edit-row-chevron {
  width: 8px; height: 8px;
  flex-shrink: 0;
  position: relative;
  transition: transform var(--dur-2) var(--ease);
}

.expandable-edit-row-chevron::before {
  content: "";
  position: absolute;
  inset: 0;
  border-right: 1.5px solid currentColor;
  border-bottom: 1.5px solid currentColor;
  transform: translate(-1px, -1px) rotate(-45deg);
}

.expandable-edit-row.is-open .expandable-edit-row-chevron { transform: rotate(90deg); }

@media (prefers-reduced-motion: reduce) {
  .expandable-edit-row-chevron { transition: none; }
}

.expandable-edit-row-title {
  font: 500 13.5px/1.3 var(--f-body);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.expandable-edit-row-caption {
  font: 400 12.5px/1.4 var(--f-body);
  color: var(--fg-soft);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.expandable-edit-row-meta {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
}

.expandable-edit-row-body {
  border-top: 1px solid var(--hair-soft);
  padding: var(--s-3) var(--s-4);
}
import { useState } from "react";
import { ExpandableEditRow, Textarea } from "@magicblocksai/ui";

function Example() {
  const [answer, setAnswer] = useState("");
  return (
    <ExpandableEditRow
      title="What hours are you open?"
      caption="Asked 12 times this week."
    >
      <Textarea
        rows={4}
        value={answer}
        onChange={(e) => setAnswer(e.target.value)}
        placeholder="Write a Q&A answer…"
      />
    </ExpandableEditRow>
  );
}

5.36 RichTextContent

Read-only renderer for rich-text HTML authored in <RichTextEditor> or <RichTextEditorPro>. Sanitises stored HTML against the kit allowlist; no editor engine, no client boundary.

RichTextContent

.rich-text-content

Renders saved bodies on detail pages and activity feeds. Server-renderable.

Q3 onboarding plan

Owner: @Priya Nair — kickoff next Tuesday.

  • Confirm sandbox access
  • Draft the success metrics
Keep the first call to 30 minutes.
<div class="rich-text-content">
  <h3>Q3 onboarding plan</h3>
  <p>Owner: <span data-mention data-id="u_18">@Priya Nair</span> — kickoff <strong>next Tuesday</strong>.</p>
  <ul>
    <li>Confirm sandbox access</li>
    <li>Draft the success metrics</li>
  </ul>
  <blockquote>Keep the first call to 30 minutes.</blockquote>
</div>
.rich-text-content {
  color: var(--ink);
  font: 400 15px/1.6 var(--f-body);
}
.rich-text-content span[data-mention] {
  color: var(--accent);
  font-weight: 550;
}
import { RichTextContent } from "@magicblocksai/ui";

function NoteBody({ html }: { html: string }) {
  return <RichTextContent html={html} />;
}

5.37 RichTextEditorPro

TipTap-backed rich text editor — slash palette, @ mentions, {{ }} merge tags, known-platform embeds, inline media. Ships from the @magicblocksai/ui/editor subpath so non-editor consumers never bundle TipTap. The demo below is a static mock; the editor is a client-only React component.

RichTextEditorPro

.rte-pro

Static preview of the editor chrome — toolbar above a content surface.

Renewal call — Northwind

Owner @Dana Lee. Type / for blocks, @ to mention.

  • Confirm seat count
  • Walk through the new usage report
<!-- Static preview only. The real editor is a React component;
     see the React tab. -->
<div class="rte-pro">
  <div class="rte-pro-toolbar">…</div>
  <div class="rte-pro-content">…</div>
</div>
.rte-pro {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--hair);
  border-radius: var(--r-md);
  background: var(--paper);
}
.rte-pro:focus-within {
  border-color: var(--accent);
  box-shadow: var(--sh-focus);
}
import { useState } from "react";
import { RichTextEditorPro } from "@magicblocksai/ui/editor";

function Composer() {
  const [html, setHtml] = useState("");
  return (
    <RichTextEditorPro
      value={html}
      onChange={setHtml}
      slash
      embed
      placeholder="Write something…"
    />
  );
}