Skip to content

Theming

Colors, shapes, fonts, launcher styles, theme: "host" and ::part() styling.

Last updated

Make the widget match your brand — colors, shapes, typography, and launcher style.


TL;DR#

ts
init({
  publicKey: 'agent_pk_...',
  aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000',
  theme: {
    accent: '#ff3e00', // your brand primary
    accentText: '#ffffff', // on-accent text
    radius: '12px', // corner rounding
    fontFamily: 'Inter, system-ui, sans-serif',
  },
  launcher: {
    variant: 'pill', // circle / pill / bar / minimal
    label: 'Ask us',
    icon: 'spark', // chat / message / bubbles / spark / wand / bot / help / lifebuoy / headset / search / sparkles / concierge / wave
  },
  composer: {
    sendButton: { shape: 'pill', variant: 'outline' },
  },
});

Rest of this page goes deep on each knob.


Theme modes#

Four modes:

  • "auto" — follows the visitor's OS preference (prefers-color-scheme).
  • "light" — force light mode.
  • "dark" — force dark mode.
  • "host" — follows your page's own light/dark and flips with your site's toggle. The widget reads, in order, a dark / light class on <html> or <body>, a data-theme, data-color-scheme, data-mode or data-bs-theme attribute there, then the page's color-scheme, then the OS.
ts
init({ theme: 'auto' }); // default
init({ theme: 'light' });
init({ theme: 'dark' });
init({ theme: 'host' }); // or <web-ai-chat theme="host">, data-theme="host" on the script tag
init({ theme: { mode: 'host', accent: '#635146' } }); // a mode beside token overrides

A page that sets only the mode keeps the deployment's token overrides from the dashboard (its brand accent) — so <web-ai-chat theme="host"> follows your site without losing the brand colours.

Runtime switching#

End users can flip the theme through the theme row in the header's ⋯ menu. Their choice persists across conversations and syncs across devices.

If you don't want a runtime picker, remove the action:

ts
init({
  header: {
    actions: ['textSize', 'history', 'close', 'clear', 'expand', 'fullscreen', 'sound', 'language'],
  },
});

Color overrides#

Theme properties you can override:

OptionCSS tokenPurposeDefault (help.ai brand)
theme.accent--helpai-accentPrimary brand color#10B981
theme.accentText--helpai-accent-textText shown on the accent color#FFFFFF
theme.radius--helpai-radiusCorner rounding for buttons, cards, pills10px
theme.fontFamily--helpai-fontWidget-wide font stackInter, system-ui, -apple-system, Segoe UI, sans-serif
theme.contentMaxWidth--helpai-conversation-maxMax width of the readable content column on a wide surface (fullscreen / page / standalone / wide inline) — content centres, bars stay full-bleed. 100% disables the cap. Modal + drawer fill their card by default.768px
ts
init({
  theme: {
    accent: '#ff3e00',
    accentText: '#fff',
    radius: '16px',
    fontFamily: '"Söhne", -apple-system, sans-serif',
  },
});

All of these are optional — any you don't set inherits the brand default.

Where the accent shows up#

Anywhere the widget draws "chrome" in your brand color:

  • The launcher FAB background.
  • Send / Stop button (when composer.sendButton.variant: "filled").
  • Active language/theme menu checkmarks.
  • Link colors in rendered markdown.
  • Agent badge status dot.
  • Focus rings.

Picking an accent#

Pick any brand color — the widget adapts it per theme so it stays readable, so a low-contrast raw color won't render an unreadable widget:

  • accent's hue + chroma are honored; only its lightness is nudged into the visible band for the active theme (a near-black accent lightens on dark, a near-white one darkens on light).
  • On-accent text (the FAB / send glyph) is auto-derived to black or white, so accentText is optional — set it only to force a specific on-fill color (it's used as-is only on browsers without CSS relative-color support).
  • Accent-colored text (links, active tabs) uses a separate readable shade that clears WCAG AA (4.5:1) on the panel background for every hue.

The one thing to verify yourself is the FAB against your own page background — it sits on your site, outside the widget's themed surface.

Quick contrast check (optional):

js
// Paste in DevTools to check contrast
function contrast(a, b) {
  // WCAG relative luminance formula
  const lum = (hex) => {
    const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
    const lin = (c) => (c < 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
    return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
  };
  const [la, lb] = [lum(a), lum(b)].sort((x, y) => y - x);
  return ((la + 0.05) / (lb + 0.05)).toFixed(2);
}
contrast('#ff3e00', '#ffffff'); // 3.79 — OK for large text, fail for small

Launcher#

The floating bubble + its nudges. floating mode only.

Variants#

ts
init({ launcher: { variant: 'circle' } }); // default — icon only, round
init({ launcher: { variant: 'pill', label: 'Ask AI' } }); // rounded rectangle
init({ launcher: { variant: 'bar', label: 'Need help?' } }); // wider rectangle
init({ launcher: { variant: 'minimal', label: 'Chat' } }); // text-only
VariantVisualBest for
circle56px round bubbleClassic support chat
pillIcon + label rounded"Ask AI" marketing CTA
barWider prominent barHigh-intent pages
minimalTransparent, text-onlyBlends into custom site chrome

Sizes#

ts
init({ launcher: { size: 'sm' } }); // 44px — compact
init({ launcher: { size: 'md' } }); // 56px — default
init({ launcher: { size: 'lg' } }); // 68px — prominent

Icons#

ts
init({ launcher: { icon: 'chat' } }); // default — speech bubble
init({ launcher: { icon: 'message' } }); // message square
init({ launcher: { icon: 'bubbles' } }); // stacked chat bubbles
init({ launcher: { icon: 'spark' } }); // sparkle / magic
init({ launcher: { icon: 'wand' } }); // wand / magic
init({ launcher: { icon: 'bot' } }); // robot face
init({ launcher: { icon: 'help' } }); // question mark
init({ launcher: { icon: 'lifebuoy' } }); // support ring
init({ launcher: { icon: 'headset' } }); // support agent
init({ launcher: { icon: 'search' } }); // friendly magnifier (search + smile)
init({ launcher: { icon: 'sparkles' } }); // multi-point sparkles (AI / magic)
init({ launcher: { icon: 'concierge' } }); // concierge bell (front-desk)
init({ launcher: { icon: 'wave' } }); // waving hand (say hi)

Or pass raw SVG source for a fully custom icon:

ts
init({
  launcher: {
    icon: `<svg viewBox="0 0 24 24"><path d="M..." /></svg>`,
  },
});

SVG must be 24×24 viewbox with currentColor strokes/fills.

Callout#

A single pre-engagement bubble next to the FAB. One component, configured for the use case — greeting, attention nudge, or onboarding tip.

ts
init({
  launcher: {
    callout: {
      text: "Hey! I'm Aria. What can I help with?",
      shape: 'pill', // pill / bubble / callout
      position: 'auto', // auto / horizontal / vertical
      delayMs: 1200,
      autoDismissMs: 0, // 0 = never (dismiss on click). Or 5000 for auto-dismiss after 5s.
      animated: true, // subtle horizontal nudge animation
      persistent: true, // default — re-show on every visit (greeting behaviour)
    },
  },
});

Dismissal lifecycles

persistent is the only behavioural difference between the two typical patterns:

  • Recurring greeting (persistent: true, default) — the bubble re-shows on every visit regardless of prior dismissal. Use for warm "Hi! 👋" copy that should always greet visitors.
  • One-time hint (persistent: false) — the exact text is persisted to localStorage. Once the user clicks the X (or opens the widget once), the same copy stays hidden across visits. Reconfiguring with NEW text bypasses the prior dismissal — a new message is a new ask.

Pick persistent: false when the bubble is a CTA you don't want to nag with after the first dismissal.

Tone: short, conversational. Think "Hi! 👋" or "Click here →" — not "CLICK HERE FOR DISCOUNTS".

Hiding the launcher entirely#

Want a custom "open chat" button elsewhere on your site?

ts
const chat = init({
  publicKey: 'agent_pk_...',
  aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000',
  launcher: { hidden: true },
});

// Your button
document.querySelector('#my-chat-btn').onclick = () => chat.open();

Composer#

The input area.

Send button#

ts
init({
  composer: {
    sendButton: {
      shape: 'pill', // circle / square / pill
      icon: 'arrowUp', // arrowUp / paperPlane / send / chevron
      variant: 'filled', // filled / outline / ghost
    },
  },
});

3 × 4 × 3 = 36 combinations. Pick what matches your brand.

Quick recipes:

  • Minimal outline — { shape: 'circle', variant: 'outline', icon: 'arrowUp' }
  • Bold filled — { shape: 'pill', variant: 'filled', icon: 'paperPlane' }
  • Ghost text — { shape: 'square', variant: 'ghost', icon: 'send' }

Disclaimer line#

Below the textarea, small grey text. Classic "AI can make mistakes" pattern.

ts
init({ footer: { composerDisclaimer: 'AI-generated. Verify important details.' } });

Empty string = hidden. Keep it short (< 60 chars for mobile).

Powered-by row#

Optional attribution below the disclaimer.

ts
init({
  footer: {
    poweredBy: {
      text: 'Powered by Help AI',
      href: 'https://help.ai',
      logoUrl: 'https://cdn.help.ai/logo.svg',
    },
  },
});
  • Omitted fields inherit the active brand's attribution defaults. Set footer.poweredBy.text to "" (without a logo) to hide the row.
  • href wraps the row in a link with target="_blank" rel="noopener noreferrer".

Header actions#

See Configuration § Header actions for the full semantics. Quick customization examples:

Minimal chrome#

Strip everything but close:

ts
init({
  header: { actions: ['close'] },
});

Language-enabled#

Enable the language row for multilingual marketing. Placement and ordering are fixed by the widget; the array is only an enabled set.

ts
init({
  i18n: { availableLocales: ['en', 'fr', 'es', 'de'] },
  header: { actions: ['language', 'theme', 'textSize', 'history', 'close', 'clear', 'expand', 'fullscreen', 'sound'] },
});

History-focused chrome#

Keep the header focused on history and basic preferences:

ts
init({
  header: { actions: ['history', 'close', 'clear', 'sound', 'language', 'theme'] },
  features: { disableFileUpload: true, voice: 'disabled' },
});

Internationalization#

Language switcher#

Enable the language action — when more than one locale is available, the header's ⋯ menu shows one segmented language row:

ts
init({
  i18n: {
    availableLocales: ['en', 'fr', 'es', 'de', 'ja'],
    defaultLocale: 'auto', // sniff navigator.language
  },
  header: { actions: ['language', 'theme', 'history', 'close'] },
});

Locale display names come from Intl.DisplayNames in the active locale — so a French user sees "anglais, français, espagnol", not "English, French, Spanish".

The user's pick persists across devices (via the backend).

Strings overrides#

Every UI string is configurable. Two shapes:

Flat (applies to active locale):

ts
init({
  i18n: {
    strings: {
      composerPlaceholder: 'Ask me anything…',
      send: 'Go',
      newConversation: 'Start over',
    },
  },
});

Nested (per-locale):

ts
init({
  i18n: {
    strings: {
      en: { send: 'Go', composerPlaceholder: 'Ask me anything…' },
      fr: { send: 'Envoyer', composerPlaceholder: 'Demandez-moi…' },
      es: { send: 'Enviar', composerPlaceholder: 'Pregúntame…' },
    },
  },
});

Full list of string keys#

attach, attachmentMimeRejected, attachmentTooLarge,
attachmentTooMany, attachmentUploadFailed, conversationClosed, close, collapse,
composerPlaceholder, dateLastWeek, dateOlder, dateToday,
dateYesterday, dropZone, dropZoneRejected, errorGeneric, errorRetry, expand,
exitFullscreen, fullscreen, history, historyBack, historyEmpty,
historyLoading, historyTitle, language, launcherOpen,
conversationLoading, loadMoreConversations, loadPreviousMessages, micStart, micStop, micUnsupported, moreActions,
newConversation, panelTitle, resizeHandle, send, soundOff,
soundOn, startNewConversation, stop, theme, themeAuto, themeDark,
themeLight, thinking, usedTool

Any key you don't override keeps its default English value.


Dashboard vs client-side#

Every theming option above can also be set in your Help AI dashboard. The dashboard lets non-engineers tune colors, copy, and launcher chrome without redeploying your site.

Precedence:

  1. Options you pass to init() win.
  2. Dashboard settings fill in anything you leave unset.
  3. Built-in defaults are the final fallback.

This means you can:

  • Hardcode brand colors in code (so they never drift even if someone edits the dashboard).
  • Let the dashboard own launcher copy (so marketing can A/B-test without asking engineering).
  • Mix freely — every option is independently controlled.

CSS-level customization (advanced)#

The widget renders inside a shadow root, so your page CSS can't reach it. If you need to go beyond the theme overrides, the widget exposes ~40 CSS custom properties on its host element you can tweak directly:

css
web-ai-chat,
helpai-chat-host {
  /* Colors */
  --helpai-bg: #fafafa;
  --helpai-fg: #111;
  --helpai-border: #e5e5e5;
  /* Set the accent via the `-user` slots — the widget DERIVES the theme-adapted
     `--helpai-accent` / `--helpai-accent-text` / `--helpai-accent-fg` from these,
     so overriding the derived tokens directly has no effect on modern browsers. */
  --helpai-accent-user: #ff3e00;
  --helpai-accent-text-user: #fff;

  /* Panel dimensions */
  --helpai-panel-w: 420px;
  --helpai-panel-h: 640px;
  --helpai-expanded-w: 640px;
  --helpai-expanded-h: 820px;

  /* Launcher */
  --helpai-fab-size: 56px;

  /* Typography */
  --helpai-font: "Inter", system-ui, sans-serif;
  --helpai-radius: 10px;

  /* Motion */
  --helpai-dur-base: 240ms;
  --helpai-dur-quick: 120ms;
}

These cascade through the shadow root — a single rule on the host element propagates to every component inside. Full list in src/styles/tokens.css in the source.

Styling parts#

Five elements carry part names, so your own CSS restyles them through the shadow root with ::part() — no reaching into the widget, and names that stay stable across releases:

PartElement
launcherThe floating launcher button
panelThe open panel (chat, tabbed messenger or empty state)
headerThe bar at the top of the panel (the chat, history and sub-screen headers)
messagesThe scrolling message list
composerThe message box area
css
web-ai-chat::part(launcher) {
  box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
}
web-ai-chat::part(panel) {
  border-radius: 20px;
}
web-ai-chat::part(header) {
  border-bottom-color: transparent;
}

Use helpai-chat-host::part(…) for a widget mounted with init() / the script tag. Parts don't cross an iframe, so presentation.iframe: true widgets are styled with tokens only. Prefer the tokens above for colour and size; parts are for what tokens don't cover (shadows, corners, borders).

For truly bespoke designs (e.g., replacing the launcher with your own component), use launcher.hidden: true + custom trigger, or a no-script trigger.


Per-deployment vs per-instance#

Some knobs are set at the deployment level (in your Help AI dashboard) and apply to every widget mounted for that deployment:

  • Theme defaults
  • Launcher chrome defaults
  • Welcome messages + suggestion chips
  • Agent persona (name, avatar, status)
  • Model routing
  • Tool allowlist
  • Action layout defaults
  • I18n strings

Other knobs are best set per-instance in your init() call:

  • widgetId (for multi-widget pages)
  • target (for inline/standalone)
  • user identity
  • onMessage / onOpen / onError callbacks

Any option set in init() wins over the deployment default, so you can always override.


Next#

Still need help?

Ask the community