Q3 onboarding plan
Owner: @Priya Nair — kickoff next Tuesday.
- Confirm sandbox access
- Draft the success metrics
Keep the first call to 30 minutes.
Chapter 05 · Data entry
Forms are the first real conversation a lead has with the product. They should feel calm, trustworthy, and forgiving.
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 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); }Leading icons clarify intent (mail, search). Trailing buttons affect the input (show password, clear). Prefix/suffix chips handle URLs, currency, units.
<!-- 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" />
</>
);
}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.
<!-- 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…" />
</>
);
}Resize vertical only. Pair with a character counter on the trailing side when there's a real limit.
<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>
);
}Native <select> styled to match. Keeps accessibility + mobile keyboards intact, wins every tradeoff.
<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>
);
}Pill-shaped, leading icon, optional keyboard shortcut chip. The large variant is the brand-kit search at the top of brand.magicblocks.ai.
<!-- 1. Default — pill with leading icon and ⌘K shortcut chip -->
<div class="search-field">
<span class="search-icon"><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 type="search" class="search-input" placeholder="Search leads, agents, templates…">
<kbd class="search-kbd">⌘ K</kbd>
</div>
<!-- 2. Large variant — used as the brand-kit homepage search -->
<div class="search-field search-field-lg">
<span class="search-icon"><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 type="search" class="search-input" placeholder="Ask your knowledge base anything…">
</div>
// .search-field is a CSS-only pattern in @magicblocksai/css.
export default function Demo() {
return (
<>
{/* 1. Default search */}
<div className="search-field">
<span className="search-icon"><SearchIcon /></span>
<input type="search" className="search-input"
placeholder="Search leads, agents, templates…" />
<kbd className="search-kbd">⌘ K</kbd>
</div>
{/* 2. Large variant */}
<div className="search-field search-field-lg">
<span className="search-icon"><SearchIcon /></span>
<input type="search" className="search-input"
placeholder="Ask your knowledge base anything…" />
</div>
</>
);
}
.search-field {
display: inline-flex; align-items: center;
background: var(--bg-paper);
border: 1px solid var(--hair);
border-radius: var(--r-pill);
padding: 0 var(--s-4) 0 var(--s-5);
height: 40px;
gap: var(--s-3);
min-width: 320px; max-width: 100%;
transition: border-color var(--dur-2) var(--ease), box-shadow var(--dur-2) var(--ease);
}
.search-field:focus-within { border-color: var(--accent); box-shadow: var(--sh-focus); }
.search-icon { color: var(--fg-dim); display: inline-flex; }
.search-input {
flex: 1; border: 0; background: transparent;
font: 400 14.5px/1.4 var(--f-body); color: var(--fg);
outline: 0;
/* Strip native search-input chrome — without these, WebKit shows its
own inner focus ring inside the .search-field's outer halo
("two rings" look). */
appearance: none; -webkit-appearance: none;
}
.search-input:focus { box-shadow: none; }
.search-input::-webkit-search-decoration,
.search-input::-webkit-search-cancel-button,
.search-input::-webkit-search-results-button,
.search-input::-webkit-search-results-decoration { -webkit-appearance: none; }
.search-input::placeholder { color: var(--fg-faint); }
.search-kbd {
font-family: var(--f-mono); font-size: 11px;
padding: 2px 7px; color: var(--fg-dim);
background: var(--bg-sunk);
border: 1px solid var(--hair);
border-radius: var(--r-xs);
}
.search-field-lg {
height: 52px;
border-radius: var(--r-lg);
padding: 0 var(--s-6) 0 var(--s-6);
font-size: 16px;
min-width: min(420px, 100%);
width: 100%;
max-width: 520px;
}
.search-field-lg .search-input { font-size: 16px; }A warm dropzone with dashed hair border. Uploaded files collapse into a chip showing icon + name + size + remove.
<!-- 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); }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 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); }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.
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>
);
}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.
The calendar popover uses a 7-column grid. Hover, focus, and keyboard navigation all target individual cells.
<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>
);
}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.
Pink fill up to the current value; ink-bordered circular thumb. Live value chip on the right of the label. Ticks are optional.
<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>
);
}Add ticks via <span class="tick" style="--at: 25%"> children under the track.
Two stacked native range inputs; a tiny listener keeps the handles from crossing and drives the filled band.
<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>
);
}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.
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>
);
}Six tokens define every input in the library.
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.
<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}
/>
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.
<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" }}
/>
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.
<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
/>
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.
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} />;
}
Date-range picker with a preset rail + two-month calendar. Sibling to <DatePicker>; both share the internal CalendarGrid.
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} />;
}
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.
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
/>
);
}
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.
Closed-state control with one selected chip + empty input. The dropdown opens via <Portal>.
<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…"
/>
);
}
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.
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()}
/>
);
}
Single-line input and multiline textarea with {{ }} merge-tag autocomplete. Wraps the kit’s .input chrome with a portal-mounted suggestion popover.
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} />;
}
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.
<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}
/>
);
}
A TipTap-shaped rich text editor with a sanitised contenteditable engine. Toolbar exposes bold / italic / link / list / image / attachments, with optional {{ }} merge-tag autocomplete.
Live editor renders a role="toolbar" bar above a contenteditable region; link + tag popovers open via <Portal>.
<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…"
/>
);
}
Channel-level opt-out toggle with an optional consent-event audit log. Wraps the kit’s <Switch> plus a .consent-events list below.
<div class="consent-toggle">
<div class="consent-toggle-row">
<span class="consent-toggle-meta">
<span class="consent-toggle-channel">Email</span>
<span class="consent-toggle-status consent-toggle-status-on">Opted in</span>
</span>
<label class="switch">
<input type="checkbox" checked>
<span class="switch-track"><span class="switch-thumb"></span></span>
</label>
</div>
</div>
.consent-toggle {
display: flex;
flex-direction: column;
gap: var(--s-2);
padding: var(--s-3);
border: 1px solid var(--hair);
border-radius: var(--r-md);
background: var(--paper);
}
.consent-toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-3);
}
.consent-toggle-meta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.consent-toggle-channel {
font: 500 14px/1.3 var(--f-body);
color: var(--fg);
}
.consent-toggle-status {
font: 500 12px/1.3 var(--f-body);
}
.consent-toggle-status-on { color: var(--success-text); }
.consent-toggle-status-off { color: var(--fg-dim); }
import { useState } from "react";
import { ConsentToggle } from "@magicblocksai/ui";
function Example() {
const [on, setOn] = useState(true);
return <ConsentToggle channel="email" enabled={on} onChange={setOn} />;
}
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.
Empty drop zone. Files are listed below as either thumbnails or rows once uploaded.
<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)}
/>
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.
<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} />;
}
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.
Three reorderable rows. Each row uses <SortableHandle> for the grip; the kit owns the drop-indicator overlay.
<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>
)}
/>
);
}
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.
<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"
/>
);
}
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.
<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"
/>
);
}
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.
<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}
/>
);
}
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).
<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} />;
}
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." },
]}
/>
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.
<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"
/>
);
}
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.
<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} />
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.
<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>
);
}
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.
Renders saved bodies on detail pages and activity feeds. Server-renderable.
Owner: @Priya Nair — kickoff next Tuesday.
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} />;
}
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.
Static preview of the editor chrome — toolbar above a content surface.
Owner @Dana Lee. Type / for blocks, @ to mention.
<!-- 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…"
/>
);
}