Chapter 25 · Embed

Chat widget.

The full end-user-facing chat widget. Ten runtime components — from the floating launcher and proactive bubble through messages, composer, feedback, and branding marker — all wired to a WidgetTheme config emitted by <WidgetThemeProvider> as scoped CSS custom properties. Operators design the widget's look through the Chat Appearance editor; visitors see the result live on the customer's site.

25.1 Theme provider

The root context component. Converts a WidgetTheme config object into scoped CSS custom properties — every child widget component reads from them. Wrap the entire widget tree in a single provider.

WidgetThemeProvider

.widget-theme-scope

Renders a div.widget-theme-scope that carries all --w-* custom properties derived from the theme prop. Stateless — re-renders only when theme changes.

widget-theme-scope
All --w-* custom properties are injected here. Child components read them directly — no prop-drilling required.
--w-shell-header-bg: #C69C6D
--w-shell-header-text: #FCFCFC
--w-shell-chat-bg: #FCFCFC
<div class="widget-theme-scope" style="
  --w-shell-header-bg: #C69C6D;
  --w-shell-header-text: #FCFCFC;
  --w-shell-chat-bg: #FCFCFC;
">
  <!-- widget components go here -->
</div>
.widget-theme-scope {
  --w-font-family: Inter, system-ui, sans-serif;
  --w-font-weight: 400;
  --w-font-size: 14px;
  --w-line-height: 22px;
  font-family: var(--w-font-family);
  font-weight: var(--w-font-weight);
  font-size: var(--w-font-size);
  line-height: var(--w-line-height);
  color-scheme: light;
}
import { WidgetThemeProvider } from '@magicblocksai/ui';

const theme = {
  launcher: { bg: '#C69C6D', icon: '#FFFFFF' },
  shell: { headerBg: '#C69C6D', headerText: '#FCFCFC', chatBg: '#FCFCFC' },
  // … full WidgetTheme shape
};

<WidgetThemeProvider theme={theme}>
  {/* widget components go here */}
</WidgetThemeProvider>

25.2 Launcher

The floating bubble that opens the chat panel. Reads colour, icon, size, position, and optional image background from WidgetTheme.launcher. Stateless — the consumer owns open + onClick.

WidgetLauncher

.widget-launcher

Default launcher with chat icon. Add a .widget-launcher-badge child for unread counts. Set has-image and the --w-launcher-image CSS variable for image backgrounds.

Default
Warm + badge
Blue
Pink
Image bg
<!-- Default -->
<button class="widget-launcher" aria-label="Open chat">
  <span class="widget-launcher-disc"></span>
  <span class="widget-launcher-icon">
    <svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">
      <!-- Bubble fill follows --w-launcher-icon (currentColor);
           dots knock through in --w-launcher-bg so they read on every variant. -->
      <path d="M4 6a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H9l-5 4v-4a3 3 0 0 1 0-6V6z" fill="currentColor"/>
      <circle cx="8.5" cy="10" r="1.45" fill="var(--w-launcher-bg, #18181B)"/>
      <circle cx="12" cy="10" r="1.45" fill="var(--w-launcher-bg, #18181B)"/>
      <circle cx="15.5" cy="10" r="1.45" fill="var(--w-launcher-bg, #18181B)"/>
    </svg>
  </span>
  <!-- Optional badge: -->
  <span class="widget-launcher-badge" aria-label="2 unread">2</span>
</button>
.widget-launcher {
  appearance: none;
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--w-launcher-size, 60px);
  height: var(--w-launcher-size, 60px);
  border-radius: 999px;
  border: 0;
  cursor: pointer;
  color: var(--w-launcher-icon, #FFFFFF);
  background: transparent;
  transition: transform var(--dur-2) var(--ease), filter var(--dur-2) var(--ease);
}

.widget-launcher.is-floating {
  position: fixed;
  z-index: 9998;
  bottom: var(--w-launcher-bottom, 40px);
}

.widget-launcher.is-floating.is-pos-right { right: var(--w-launcher-side, 40px); }

.widget-launcher.is-floating.is-pos-left { left: var(--w-launcher-side, 40px); }

.widget-launcher:hover { transform: translateY(-2px); }

.widget-launcher:focus-visible {
  outline: 3px solid color-mix(in oklab, var(--w-launcher-bg, var(--ink)) 40%, transparent);
  outline-offset: 2px;
}

.widget-launcher-disc {
  position: absolute;
  inset: 0;
  border-radius: 999px;
  background: var(--w-launcher-bg, #18181B);
  background-image: var(--w-launcher-image, none);
  background-size: cover;
  background-position: center;
  box-shadow: 0 8px 20px color-mix(in oklab, #000 22%, transparent);
  transition: background var(--dur-2) var(--ease);
}

.widget-launcher.has-image .widget-launcher-disc {
  /* When image is set, dim slightly so the icon stays visible. */
  filter: brightness(0.9);
}

.widget-launcher-icon {
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--w-launcher-icon, #FFFFFF);
}

.widget-launcher-badge {
  position: absolute;
  top: 0;
  right: 0;
  min-width: 20px;
  height: 20px;
  padding: 0 6px;
  border-radius: 999px;
  background: #EC4899;
  color: #FFFFFF;
  font: 700 11px/1 var(--f-mono);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 2px solid var(--w-launcher-bg, #18181B);
  z-index: 2;
}

@media (prefers-reduced-motion: reduce) {
  .widget-launcher,
  .widget-launcher-disc { transition: none; }
  .widget-launcher:hover { transform: none; }
}
import { WidgetLauncher } from '@magicblocksai/ui';

<WidgetLauncher
  onClick={() => setOpen(true)}
  unreadCount={2}
/>

{/* With image background */}
<WidgetLauncher
  onClick={() => setOpen(true)}
  imageUrl="https://example.com/logo.png"
/>

25.3 Proactive message

The pre-engagement bubble that pops out beside the launcher. The visitor sees it before clicking open. Clicking the body opens the chat; clicking × dismisses the proactive bubble only — the launcher stays.

WidgetProactiveMessage

.widget-proactive

Themed via --w-proactive-bg, --w-proactive-text, --w-proactive-border, and --w-proactive-radius. Renders an avatar, agent name, and message preview.

<div class="widget-proactive"
  style="--w-proactive-bg:#FFFFFF;--w-proactive-text:#18181B;
         --w-proactive-border:#E4E4E7;--w-proactive-radius:12px;">
  <button class="widget-proactive-body" type="button">
    <span class="widget-proactive-avatar">CW</span>
    <span class="widget-proactive-text">
      <span class="widget-proactive-title">Charlie's Wines</span>
      <span class="widget-proactive-message">Hi! Looking for the perfect wine?</span>
    </span>
  </button>
  <button class="widget-proactive-dismiss" aria-label="Dismiss">&times;</button>
</div>
.widget-proactive {
  display: inline-flex;
  align-items: stretch;
  max-width: 320px;
  background: var(--w-proactive-bg, #FFFFFF);
  border: 1px solid var(--w-proactive-border, var(--hair));
  border-radius: var(--w-proactive-radius, 12px);
  color: var(--w-proactive-text, var(--ink));
  box-shadow: 0 8px 24px color-mix(in oklab, #000 16%, transparent);
  overflow: hidden;
}

.widget-proactive.is-floating {
  position: fixed;
  z-index: 9997;
  bottom: calc(var(--w-launcher-bottom, 40px) + var(--w-proactive-offset, 88px));
}

.widget-proactive.is-floating.is-pos-right { right: var(--w-launcher-side, 40px); }

.widget-proactive.is-floating.is-pos-left { left: var(--w-launcher-side, 40px); }

.widget-proactive-body {
  appearance: none;
  background: transparent;
  border: 0;
  flex: 1;
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 10px;
  padding: 12px 14px;
  text-align: left;
  cursor: pointer;
  color: inherit;
  font: inherit;
}

.widget-proactive-body:hover {
  background: color-mix(in oklab, var(--w-proactive-bg, #FFFFFF) 88%, var(--ink));
}

.widget-proactive-avatar {
  width: 32px;
  height: 32px;
  border-radius: 999px;
  background: color-mix(in oklab, var(--accent) 20%, var(--bg-warm));
  color: var(--accent);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font: 600 12px/1 var(--f-mono);
}

.widget-proactive-text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.widget-proactive-title { font: 600 13px/1.3 var(--f-body); }

.widget-proactive-message { font: 400 13px/1.5 var(--f-body); }

.widget-proactive-dismiss {
  appearance: none;
  background: transparent;
  border: 0;
  border-left: 1px solid var(--w-proactive-border, var(--hair));
  width: 32px;
  cursor: pointer;
  color: color-mix(in oklab, var(--w-proactive-text, var(--ink)) 60%, transparent);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background var(--dur-2) var(--ease);
}

.widget-proactive-dismiss:hover {
  background: color-mix(in oklab, var(--w-proactive-bg, #FFFFFF) 90%, var(--ink));
}
import { WidgetProactiveMessage } from '@magicblocksai/ui';

<WidgetProactiveMessage
  agentName="Charlie's Wines"
  agentInitials="CW"
  message="Hi! Looking for the perfect wine?"
  onOpen={() => setOpen(true)}
  onDismiss={() => setProactiveVisible(false)}
/>

25.4 Full shell

The expanded chat panel assembled into a complete widget. Header → welcome disclaimer → transcript → quick-reply row → composer → legal disclaimer → branding marker. Every slot is driven by the theme; consumers override individual slots with explicit nodes where needed.

WidgetShell

.widget-shell

The container that composes all sub-components into the full chat experience. Wraps in a widget-theme-scope and sizes to --w-shell-width × --w-shell-height.

CW
Charlie's Wines Online · usually replies in 1 min
You're chatting with an AI assistant. It may make mistakes sometimes.
CW
Charlie's Wines
Hi! 👋 Looking for the perfect wine, or browsing today?
2:14 PM
Looking for a Hunter Valley red under $50.
2:14 PM
CW
Great choice — Hunter has some bold Shiraz this season. Which style do you prefer?
2:14 PM
Powered by MagicBlocks
<div class="widget-theme-scope" style="--w-shell-header-bg:#C69C6D; …">
  <div class="widget-shell">
    <header class="widget-shell-header">
      <span class="widget-shell-avatar">CW</span>
      <div class="widget-shell-header-text">
        <span class="widget-shell-agent">Charlie's Wines</span>
        <span class="widget-shell-subtitle">Online · usually replies in 1 min</span>
      </div>
      <button class="widget-shell-close" aria-label="Close chat">×</button>
    </header>
    <div class="widget-shell-welcome">You're chatting with an AI assistant.</div>
    <div class="widget-shell-scroll">
      <div class="widget-shell-list">
        <!-- WidgetMessage entries -->
      </div>
    </div>
    <div class="widget-shell-composer"><!-- WidgetComposer --></div>
    <div class="widget-shell-legal">I'm an AI agent…</div>
    <div class="widget-shell-branding"><!-- WidgetBrandingMarker --></div>
  </div>
</div>
.widget-shell {
  display: flex;
  flex-direction: column;
  width: var(--w-shell-width, 380px);
  height: var(--w-shell-height, 560px);
  max-height: calc(100vh - 80px);
  background: var(--w-shell-chat-bg, #FCFCFC);
  border-radius: 16px;
  overflow: hidden;
  box-shadow: 0 20px 50px color-mix(in oklab, #000 24%, transparent);
  border: 1px solid color-mix(in oklab, #000 8%, transparent);
}

.widget-shell.is-floating {
  position: fixed;
  z-index: 9999;
  bottom: calc(var(--w-launcher-bottom, 40px) + var(--w-launcher-size, 60px) + 16px);
}

.widget-shell.is-floating.is-pos-right { right: var(--w-launcher-side, 40px); }

.widget-shell.is-floating.is-pos-left { left: var(--w-launcher-side, 40px); }

@media (max-width: 540px) {
  .widget-shell.is-floating {
    position: fixed;
    inset: 0;
    width: 100vw;
    height: 100vh;
    max-height: 100vh;
    border-radius: 0;
  }
}

.widget-shell-header {
  display: grid;
  grid-template-columns: auto 1fr auto auto;
  align-items: center;
  gap: 10px;
  padding: 14px 16px;
  background: var(--w-shell-header-bg, #18181B);
  background-image: var(--w-shell-bg-image, none);
  background-size: cover;
  background-position: center;
  color: var(--w-shell-header-text, #FCFCFC);
  flex-shrink: 0;
}

.widget-shell-avatar {
  width: 32px;
  height: 32px;
  border-radius: 999px;
  background: color-mix(in oklab, var(--w-shell-header-text, #FFFFFF) 18%, transparent);
  color: var(--w-shell-header-text, #FFFFFF);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font: 600 13px/1 var(--f-mono);
}

.widget-shell-header-text {
  display: flex;
  flex-direction: column;
  gap: 1px;
  min-width: 0;
}

.widget-shell-agent {
  font: 600 15px/1.3 var(--f-display);
  color: inherit;
}

.widget-shell-subtitle {
  font: 400 12px/1.3 var(--f-body);
  opacity: 0.78;
}

.widget-shell-actions { display: inline-flex; align-items: center; gap: 4px; }

.widget-shell-close {
  appearance: none;
  background: transparent;
  border: 0;
  width: 28px;
  height: 28px;
  border-radius: 999px;
  color: inherit;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background var(--dur-2) var(--ease);
}

.widget-shell-close:hover {
  background: color-mix(in oklab, var(--w-shell-header-text, #FFFFFF) 12%, transparent);
}

/* …additional rules trimmed for brevity — see _shared.css */
import { WidgetThemeProvider, WidgetShell } from '@magicblocksai/ui';

<WidgetThemeProvider theme={theme}>
  <WidgetShell
    agentName="Charlie's Wines"
    agentInitials="CW"
    subtitle="Online · usually replies in 1 min"
    messages={messages}
    onClose={() => setOpen(false)}
    onSend={handleSend}
  />
</WidgetThemeProvider>

25.5 Messages

Two sides — is-from-user and is-from-ai. Theme-driven colours, border, and radius per side. Smart 12h or 24h timestamp formatting. Optional AI-name row. Streaming and failed-delivery states built in.

WidgetMessage

.widget-msg

Add is-streaming for the mid-stream caret, or is-failed (with role="alert") for failed delivery with a retry prompt.

CW
Charlie
Hi there 👋
2:14 PM
Hi! Quick question about pricing.
2:14 PM
CW
Looking that up for you
<!-- AI message -->
<div class="widget-msg is-from-ai">
  <div class="widget-msg-avatar">CW</div>
  <div class="widget-msg-stack">
    <div class="widget-msg-name">Charlie</div>
    <div class="widget-msg-bubble">
      <div class="widget-msg-body">Hi there 👋</div>
    </div>
    <div class="widget-msg-time">2:14 PM</div>
  </div>
</div>

<!-- User message -->
<div class="widget-msg is-from-user">
  <div class="widget-msg-stack">
    <div class="widget-msg-bubble">
      <div class="widget-msg-body">Quick question about pricing.</div>
    </div>
  </div>
</div>

<!-- Streaming -->
<div class="widget-msg is-from-ai is-streaming">…<span class="widget-msg-caret" aria-hidden="true"></span></div>

<!-- Failed -->
<div class="widget-msg is-from-user is-failed" role="alert">
  <div class="widget-msg-stack">
    <div class="widget-msg-bubble"><div class="widget-msg-body">…</div></div>
    <div class="widget-msg-failed">Couldn't deliver — tap to retry.</div>
  </div>
</div>
.widget-msg {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: 8px;
  align-items: flex-end;
}

.widget-msg.is-from-user {
  grid-template-columns: minmax(0, 1fr);
  justify-items: end;
}

.widget-msg-avatar {
  width: 28px;
  height: 28px;
  border-radius: 999px;
  background: color-mix(in oklab, var(--w-msg-ai-bubble, #FFFFFF) 80%, var(--ink) 20%);
  color: var(--w-msg-ai-text, var(--ink));
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font: 600 11px/1 var(--f-mono);
  align-self: flex-end;
  margin-bottom: 4px;
  flex-shrink: 0;
}

.widget-msg-stack {
  display: flex;
  flex-direction: column;
  gap: 2px;
  max-width: 86%;
}

.widget-msg.is-from-user .widget-msg-stack { align-items: flex-end; }

.widget-msg-name {
  font: 500 11.5px/1 var(--f-body);
  color: var(--w-msg-ai-name, #2B2B30);
  padding: 0 4px 2px;
}

.widget-msg-bubble {
  position: relative;
  padding: 10px 14px;
  font: 400 14px/1.5 var(--f-body);
  word-break: break-word;
}

.widget-msg.is-from-user .widget-msg-bubble {
  color: var(--w-msg-user-text, #FFFFFF);
  background: var(--w-msg-user-bubble, #18181B);
  border: 1px solid var(--w-msg-user-border, transparent);
  border-radius: var(--w-msg-user-radius, 8px);
}

.widget-msg.is-from-ai .widget-msg-bubble {
  color: var(--w-msg-ai-text, #18181B);
  background: var(--w-msg-ai-bubble, #FFFFFF);
  border: 1px solid var(--w-msg-ai-border, var(--hair));
  border-radius: var(--w-msg-ai-radius, 8px);
}

.widget-msg.is-failed.is-from-user .widget-msg-bubble,
.widget-msg.is-failed.is-from-ai .widget-msg-bubble {
  box-shadow: 0 0 0 1.5px #C0392B;
}

.widget-msg-body { white-space: pre-wrap; }

.widget-msg-body > *:first-child { margin-top: 0; }

.widget-msg-body > *:last-child { margin-bottom: 0; }

.widget-msg-caret {
  display: inline-block;
  width: 2px;
  height: 1em;
  background: currentColor;
  margin-left: 2px;
  vertical-align: text-bottom;
  animation: widget-msg-caret 1s steps(2, end) infinite;
}

@media (prefers-reduced-motion: reduce) {
  .widget-msg-caret { animation: none; opacity: 0.6; }
}

.widget-msg-time {
  font: 400 10.5px/1 var(--f-mono);
  padding: 2px 4px 0;
  font-variant-numeric: tabular-nums;
}

.widget-msg.is-from-user .widget-msg-time { color: var(--w-msg-user-time, var(--fg-soft)); }

.widget-msg.is-from-ai .widget-msg-time { color: var(--w-msg-ai-time, #2B2B30); }

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

<WidgetMessage
  role="ai"
  agentInitials="CW"
  agentName="Charlie"
  body="Hi there 👋"
  timestamp="2:14 PM"
/>

<WidgetMessage role="user" body="Hi! Quick question." timestamp="2:14 PM" />

{/* Streaming */}
<WidgetMessage role="ai" agentInitials="CW" body="Looking that up…" streaming />

{/* Failed */}
<WidgetMessage role="user" body="Can I get a refund?" failed onRetry={handleRetry} />

25.6 Composer

Auto-growing textarea plus a customisable Send button. The Send label, icon, icon position, and all colours read from theme.send. The textarea grows up to maxRows then scrolls.

WidgetComposer

.widget-composer

Three Send variants via modifier classes on .widget-composer-send: is-icon-before (icon left of label), is-icon-after (icon right), and is-icon-only (compact circle).

<form class="widget-composer">
  <div class="widget-composer-row">
    <textarea class="widget-composer-input" rows="1"
      placeholder="Ask me anything…" aria-label="Message"></textarea>
    <!-- is-icon-before | is-icon-after | is-icon-only -->
    <button type="submit"
      class="widget-composer-send is-icon-before" aria-label="Send">
      <span class="widget-composer-send-icon"><!-- SVG --></span>
      <span class="widget-composer-send-label">Send</span>
    </button>
  </div>
</form>
.widget-composer { display: flex; flex-direction: column; gap: 4px; }

.widget-composer.is-disabled { opacity: 0.55; pointer-events: none; }

.widget-composer-row {
  display: flex;
  align-items: flex-end;
  gap: 8px;
  padding: 4px 4px 4px 10px;
  background: var(--w-composer-bg, #FFFFFF);
  border: 1px solid var(--w-composer-border, var(--hair));
  border-radius: 12px;
  transition: border-color var(--dur-2) var(--ease),
              box-shadow var(--dur-2) var(--ease);
}

.widget-composer-row:focus-within {
  border-color: var(--w-msg-user-bubble, var(--ink));
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--w-msg-user-bubble, var(--ink)) 20%, transparent);
}

.widget-composer-attachments {
  display: inline-flex;
  align-items: center;
  gap: 2px;
}

.widget-composer-input {
  flex: 1;
  appearance: none;
  background: transparent;
  border: 0;
  outline: none;
  resize: none;
  box-sizing: border-box;
  padding: 8px 0;
  font: 400 var(--w-composer-font-size, 16px) / 1.4 var(--w-font-family, var(--f-body));
  color: var(--w-composer-text, var(--ink));
  /* Auto-grow up to three lines, then scroll. */
  field-sizing: content;
  min-height: 40px;
  max-height: 84px;
  overflow-y: auto;
}

.widget-composer-input::placeholder { color: var(--w-composer-placeholder, #A1A1AA); }

.widget-composer-send {
  appearance: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  height: 36px;
  padding: 0 14px;
  border: 1px solid var(--w-send-border, transparent);
  border-radius: var(--w-send-radius, 8px);
  background: var(--w-send-bg, #18181B);
  color: var(--w-send-text, #FFFFFF);
  font: 600 13px/1 var(--w-font-family, var(--f-body));
  cursor: pointer;
  transition: filter var(--dur-2) var(--ease);
  flex-shrink: 0;
}

.widget-composer-send.is-icon-only { padding: 0 10px; }

.widget-composer-send.is-label-only { gap: 0; }

.widget-composer-send:hover:not(:disabled) { filter: brightness(0.9); }

.widget-composer-send:disabled { opacity: 0.5; cursor: not-allowed; }

.widget-composer-send-icon { display: inline-flex; }

.widget-composer-send-label { line-height: 1; }

.widget-composer-spinner {
  width: 14px;
  height: 14px;
  border-radius: 999px;
  border: 1.5px solid currentColor;
  border-right-color: transparent;
  animation: widget-composer-spin 0.7s linear infinite;
}

@media (prefers-reduced-motion: reduce) {
  .widget-composer-spinner { animation: none; opacity: 0.5; }
}
import { WidgetComposer } from '@magicblocksai/ui';

<WidgetComposer
  placeholder="Ask me anything…"
  sendLabel="Send"
  sendIconPosition="before"  // 'before' | 'after' | 'only'
  onSend={handleSend}
/>

25.7 Buttons

Three variants — primary, secondary, and suggestion. Each carries independently themable text colour, background, border, and radius. Use primary for CTAs inside bubbles, secondary for alternatives, and suggestion for quick-reply chips above the composer.

WidgetButton

.widget-button

Modifier classes: is-primary, is-secondary, is-suggestion. Add <span class="widget-button-spinner"></span> for a loading state.

Default (kit ink)
Warm brand
Bold blue brand
<!-- Three variants -->
<button class="widget-button is-primary">Book a demo</button>
<button class="widget-button is-secondary">Not now</button>
<button class="widget-button is-suggestion">Hunter Valley</button>

<!-- Loading state -->
<button class="widget-button is-primary">
  <span class="widget-button-spinner"></span>
</button>

<!-- Themed via CSS custom props on a parent -->
<div style="
  --w-btn-primary-bg: #C69C6D;
  --w-btn-primary-text: #FFFFFF;
  --w-btn-primary-radius: 8px;">
  <button class="widget-button is-primary">Add to cart</button>
</div>
.widget-button {
  appearance: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 8px 14px;
  border: 1px solid transparent;
  cursor: pointer;
  font: 600 13px/1 var(--w-font-family, var(--f-body));
  transition: filter var(--dur-2) var(--ease);
}

.widget-button:hover:not(:disabled) { filter: brightness(0.92); }

.widget-button:disabled { opacity: 0.5; cursor: not-allowed; }

.widget-button:focus-visible {
  outline: 2px solid color-mix(in oklab, var(--accent) 40%, transparent);
  outline-offset: 2px;
}

.widget-button.is-primary {
  color: var(--w-btn-primary-text, #FFFFFF);
  background: var(--w-btn-primary-bg, #18181B);
  border-color: var(--w-btn-primary-border, transparent);
  border-radius: var(--w-btn-primary-radius, 8px);
}

.widget-button.is-secondary {
  color: var(--w-btn-secondary-text, #18181B);
  background: var(--w-btn-secondary-bg, #FFFFFF);
  border-color: var(--w-btn-secondary-border, var(--hair));
  border-radius: var(--w-btn-secondary-radius, 8px);
}

.widget-button.is-suggestion {
  color: var(--w-btn-suggest-text, #18181B);
  background: var(--w-btn-suggest-bg, transparent);
  border-color: var(--w-btn-suggest-border, var(--hair));
  border-radius: var(--w-btn-suggest-radius, 999px);
  padding: 6px 12px;
  font-size: 12.5px;
}

.widget-button-icon { display: inline-flex; }

.widget-button-spinner {
  width: 14px;
  height: 14px;
  border-radius: 999px;
  border: 1.5px solid currentColor;
  border-right-color: transparent;
  animation: widget-composer-spin 0.7s linear infinite;
}

@media (prefers-reduced-motion: reduce) {
  .widget-button { transition: none; }
}
import { WidgetButton } from '@magicblocksai/ui';

<WidgetButton variant="primary" onClick={handleCTA}>Book a demo</WidgetButton>
<WidgetButton variant="secondary" onClick={handleDismiss}>Not now</WidgetButton>
<WidgetButton variant="suggestion" onClick={() => prefill('Hunter Valley')}>
  Hunter Valley
</WidgetButton>

{/* Loading state */}
<WidgetButton variant="primary" loading />

25.8 Feedback

Thumbs up / down pair rendered beneath AI messages. Active state colours the icon via --w-feedback-icon-active. Three states: idle, up-active, and down-active.

WidgetFeedback

.widget-feedback

Each button carries aria-pressed and aria-label. Add is-active to the pressed button and update aria-pressed="true".

Idle
Thumbs up
Thumbs down
<div class="widget-feedback" role="group" aria-label="Message feedback">
  <button type="button" class="widget-feedback-btn is-up"
    aria-pressed="false" aria-label="Helpful">
    <!-- thumbs-up SVG -->
  </button>
  <button type="button" class="widget-feedback-btn is-down"
    aria-pressed="false" aria-label="Not helpful">
    <!-- thumbs-down SVG -->
  </button>
</div>

<!-- Active state: add is-active + set aria-pressed="true" -->
.widget-feedback {
  display: inline-flex;
  gap: 2px;
  padding: 2px;
  background: var(--w-feedback-bg, #FFFFFF);
  border-radius: 999px;
  border: 1px solid color-mix(in oklab, #000 6%, transparent);
}

.widget-feedback.is-disabled { opacity: 0.55; pointer-events: none; }

.widget-feedback-btn {
  appearance: none;
  background: transparent;
  border: 0;
  width: 26px;
  height: 26px;
  border-radius: 999px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--w-feedback-icon, #18181B);
  cursor: pointer;
  transition: background var(--dur-2) var(--ease),
              color var(--dur-2) var(--ease);
}

.widget-feedback-btn:hover {
  background: color-mix(in oklab, var(--w-feedback-bg, #FFFFFF) 80%, var(--ink));
}

.widget-feedback-btn.is-active {
  color: var(--w-feedback-icon-active, var(--accent));
  background: color-mix(in oklab, var(--w-feedback-icon-active, var(--accent)) 14%, var(--w-feedback-bg, #FFFFFF));
}
import { WidgetFeedback } from '@magicblocksai/ui';

<WidgetFeedback
  value={feedback}           // null | 'up' | 'down'
  onFeedback={setFeedback}
/>

25.9 Disclaimers

Two variants for two positions. The welcome disclaimer sits at the top of the chat scroll area. The legal disclaimer sits at the bottom, beneath the composer.

WidgetDisclaimer

.widget-disclaimer

Add is-welcome for the top banner (themed via --w-disclaimer-welcome-bg + --w-disclaimer-welcome-text). Add is-legal for the bottom note (themed via --w-disclaimer-legal-text + --w-disclaimer-legal-align).

Welcome (top of chat)
You're chatting with an AI assistant. It may make mistakes sometimes.
Legal, left-aligned
Legal, centred
<!-- Welcome variant -->
<div class="widget-disclaimer is-welcome" role="note"
  style="--w-disclaimer-welcome-bg:#E4E4E7;--w-disclaimer-welcome-text:#2B2B30;">
  You're chatting with an AI assistant.
</div>

<!-- Legal variant -->
<div class="widget-disclaimer is-legal" role="note"
  style="--w-disclaimer-legal-text:#18181B;--w-disclaimer-legal-align:left;">
  I'm an AI agent and may not be 100% accurate.
</div>
.widget-disclaimer { font: inherit; }

.widget-disclaimer.is-welcome {
  padding: 10px 14px;
  background: var(--w-disclaimer-welcome-bg, #E4E4E7);
  color: var(--w-disclaimer-welcome-text, #2B2B30);
  font: 400 12.5px/1.5 var(--f-body);
}

.widget-disclaimer.is-legal {
  padding: 8px 14px;
  font: 400 11px/1.4 var(--f-mono);
  color: var(--w-disclaimer-legal-text, var(--fg-soft));
  text-align: var(--w-disclaimer-legal-align, left);
}
import { WidgetDisclaimer } from '@magicblocksai/ui';

<WidgetDisclaimer
  variant="welcome"
  text="You're chatting with an AI assistant. It may make mistakes sometimes."
/>

<WidgetDisclaimer
  variant="legal"
  text="I'm an AI agent and may not be 100% accurate."
  align="left"
/>

25.10 Branding marker

The "Powered by MagicBlocks" footer. Plan-gated by the consumer — render it on free plans, omit it on paid plans, or override the label entirely on enterprise.

WidgetBrandingMarker

.widget-branding-marker

Three variants: linked (widget-branding-link wrapping the mark + text), static text (widget-branding-static), and omitted entirely when the consumer's plan suppresses it.

<!-- Linked (default — links to magicblocks.ai) -->
<div class="widget-branding-marker">
  <a class="widget-branding-link" href="https://magicblocks.ai"
    target="_blank" rel="noopener">
    <span class="widget-branding-mark"><!-- mark SVG --></span>
    <span class="widget-branding-text">Powered by MagicBlocks</span>
  </a>
</div>

<!-- Static (non-linked) -->
<div class="widget-branding-marker">
  <span class="widget-branding-static">
    <span class="widget-branding-text">An AI experience by Acme Co.</span>
  </span>
</div>
.widget-branding-marker {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
}

.widget-branding-link,
.widget-branding-static {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  text-decoration: none;
  color: inherit;
  font: 500 10.5px/1 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: 0.08em;
}

.widget-branding-link:hover { opacity: 0.7; }

.widget-branding-mark { display: inline-flex; }

.widget-branding-text { line-height: 1.2; }
import { WidgetBrandingMarker } from '@magicblocksai/ui';

{/* Default: shows "Powered by MagicBlocks" linked */}
<WidgetBrandingMarker />

{/* Enterprise: custom label and link */}
<WidgetBrandingMarker
  label="Powered by Tyrrells AI"
  href="https://tyrrells.com.au"
/>

{/* Static (no link) */}
<WidgetBrandingMarker
  label="An AI experience by Acme Co."
  static
/>

25.11 Brand themes

The same component tree, four different WidgetTheme configs. Every visual lever — bubble shape, colour, header chrome, launcher pill, AI-name display, timestamp format — driven by tokens. Total rebrandability.

Four widgets, four brands

.widget-theme-scope

Warm wines (earthy gold), tech SaaS (deep blue), vibrant retail (hot pink), and dark minimal (monochrome). Each uses the identical widget HTML; only the CSS custom-property set differs.

Tyrrells Wines
T
Tyrrells Online
G'day! Any wine on your mind?
Bold reds.
Linear Pro
L
Linear AI Reply in seconds
What can I help with today?
Show me Sprint velocity.
Pink Studio
P
Pink Studio ✨ Always on
Looking for something special? 💖
Show me dresses.
Mono
M
mono.ai online
help
<!-- Warm brand -->
<div class="widget-theme-scope" style="
  --w-shell-header-bg: #C69C6D;
  --w-msg-user-bubble: #C69C6D;
  --w-msg-user-radius: 8px;
  --w-send-bg: #C69C6D;
  /* … full token set */">
  <div class="widget-shell">…</div>
</div>

<!-- Dark minimal brand -->
<div class="widget-theme-scope" style="
  --w-shell-header-bg: #27272A;
  --w-shell-chat-bg: #18181B;
  --w-msg-user-bubble: #FAFAFA;
  --w-msg-user-radius: 4px;
  --w-send-bg: #FAFAFA;
  /* … */">
  <div class="widget-shell">…</div>
</div>
.widget-theme-scope {
  --w-font-family: Inter, system-ui, sans-serif;
  --w-font-weight: 400;
  --w-font-size: 14px;
  --w-line-height: 22px;
  font-family: var(--w-font-family);
  font-weight: var(--w-font-weight);
  font-size: var(--w-font-size);
  line-height: var(--w-line-height);
  color-scheme: light;
}
import { WidgetThemeProvider, WidgetShell } from '@magicblocksai/ui';

const warmTheme = {
  shell: { headerBg: '#C69C6D', headerText: '#FCFCFC', chatBg: '#FCFCFC' },
  messages: { userBubble: '#C69C6D', userText: '#FFFFFF', userRadius: 8 },
  send: { bg: '#C69C6D', text: '#FFFFFF', radius: 8 },
};

const darkTheme = {
  shell: { headerBg: '#27272A', headerText: '#FAFAFA', chatBg: '#18181B' },
  messages: { userBubble: '#FAFAFA', userText: '#18181B', userRadius: 4 },
  send: { bg: '#FAFAFA', text: '#18181B', radius: 4 },
};

<WidgetThemeProvider theme={warmTheme}>
  <WidgetShell agentName="Tyrrells" messages={messages} />
</WidgetThemeProvider>

25.12 Channels — Chat Appearances

The workspace-level catalog of chat-widget styling presets that any agent can re-use. Top sub-nav (Chat / Email / API) reflects the three workspace channel surfaces. Below: a master-detail with the appearances list on the left and a live widget preview on the right — styled with the chapter's actual widget primitives so what the operator sees here matches what the consumer's site renders.

Channels shell — Chat tab · 6 appearances · CREFCO Financial Group selected

.ch-screen · .ch-list · .ch-detail · .ch-preview-widget

Each appearance row reads its name + source URL + status (Used / Unused) at a glance. Click one and the right pane updates: the title and meta line shift to that appearance's details, the live widget preview re-renders with the chosen colours, font, and persona greeting. The bottom strip shows a 4-tile config glance (accent colour, position, font, persona name) so the operator can confirm at a glance without scrolling into the editor. Delete / Duplicate / Edit Widget actions sit in the head.

Chat Appearances · 6

Style your AI chat — design how messages, inputs, and branding show up. More on Chat Appearance →

CREFCO Financial Group
Wizard-built from cfgohio.com
Used 2d ago
Tyrrell's
Wizard-built from tyrrells.com
Used 5d ago
Becker Wines
Wizard-built from beckerwines.com
Used 1w ago
Approved Funding
Wizard-built from approvedfunding.com
Used 2w ago
Move Me In
Wizard-built from movemein.com.au
Used 3w ago
Default theme
MagicBlocks pink · system fallback
Unused never

CREFCO Financial Group Used by 2 agents

Last updated 2d ago · Created via Wizard
Live preview
Charlie · CREFCO Lead Agent
online · replies in < 1m
Hi! I'm Charlie, your guide at CREFCO Financial. Guiding you every step of the way. What brings you in today?
powered by MagicBlocks
Accent
Pink #FF3F7A
Position
Bottom right
Font
DM Sans
Persona
Charlie
<div class="ch-screen">
  <nav class="ch-subnav">
    <a class="ch-subnav-tab is-active">Chat</a>
    <a class="ch-subnav-tab">Email <span class="ch-soon">Soon</span></a>
    <a class="ch-subnav-tab">API</a>
  </nav>

  <div class="ch-body">

    <div class="ch-list">
      <div class="ch-list-head">…Chat Appearances · 6…</div>
      <div class="ch-list-toolbar">…search + Create…</div>
      <div class="ch-list-status-tabs">
        <button class="is-active">All <span class="ch-status-count">6</span></button>
        <button>Used <span class="ch-status-count">5</span></button>
        <button>Unused <span class="ch-status-count">1</span></button>
      </div>
      <div class="ch-appearances">
        <article class="ch-appearance-row is-selected">
          <span class="ch-appearance-icon">…palette…</span>
          <div class="ch-appearance-body">
            <div class="ch-appearance-name">CREFCO Financial Group</div>
            <div class="ch-appearance-sub">Wizard-built from cfgohio.com</div>
          </div>
          <div class="ch-appearance-meta">
            <span class="badge"><span class="dot dot-green"></span> Used</span>
            <span class="ch-appearance-time">2d ago</span>
          </div>
        </article>
        …more rows…
      </div>
    </div>

    <div class="ch-detail">
      <header class="ch-detail-head">…title + status + Delete/Duplicate/Edit Widget…</header>

      <div class="ch-preview-frame">
        <span class="ch-preview-label">Live preview</span>
        <div class="ch-preview-widget">
          <div class="ch-preview-widget-head">avatar + name + status</div>
          <div class="ch-preview-widget-body">
            <div class="ch-preview-bubble">Hi! I'm Charlie…</div>
            <div class="ch-preview-replies">
              <button class="ch-preview-reply">First-time buyer</button>
              <button class="ch-preview-reply">Refinance</button>
              <button class="ch-preview-reply">Just exploring</button>
            </div>
          </div>
          <div class="ch-preview-widget-foot">input + send</div>
          <div class="ch-preview-widget-brand">powered by MagicBlocks</div>
        </div>
      </div>

      <div class="ch-config-glance">
        <div class="ch-config-tile">Accent · Pink #FF3F7A</div>
        <div class="ch-config-tile">Position · Bottom right</div>
        <div class="ch-config-tile">Font · DM Sans</div>
        <div class="ch-config-tile">Persona · Charlie</div>
      </div>
    </div>
  </div>
</div>
/* Chapter-private channels shell. The preview widget is built with
   chapter-local primitives that mirror the chapter 17 widget shape
   (head + body + composer + brand strip); a production version would
   compose the actual .widget-* primitives instead. */
.ch-screen { display: flex; flex-direction: column; }
.ch-subnav { display: flex; gap: var(--s-4); border-bottom: 1px solid var(--hair); padding: 0 var(--s-6); }
.ch-body { display: grid; grid-template-columns: 360px 1fr; flex: 1; }
.ch-list { border-right: 1px solid var(--hair); background: var(--bg-paper); }
.ch-detail { background: var(--bg-2); padding: var(--s-5); }
.ch-preview-frame {
  background-image:
    linear-gradient(135deg, color-mix(in oklab, var(--accent) 3%, transparent), transparent 60%),
    repeating-linear-gradient(45deg, color-mix(in oklab, var(--ink) 2%, transparent) 0, color-mix(in oklab, var(--ink) 2%, transparent) 1px, transparent 1px, transparent 12px);
}
.ch-preview-widget-head { background: var(--accent); color: var(--paper); }
/* PROVISIONAL — composes kit primitives.
   Existing kit exports used: Badge, Dot, Av. Future: WidgetShell from 17.4. */

export function ChannelsChatPage({ appearances, selectedId, onSelect }) {
  const selected = appearances.find(a => a.id === selectedId);
  return (
    <div className="ch-screen">
      <ChannelsSubnav active="chat" />
      <div className="ch-body">
        <ChatAppearancesList items={appearances} selectedId={selectedId} onSelect={onSelect} />
        {selected && <ChatAppearanceDetail appearance={selected} />}
      </div>
    </div>
  );
}