kai-message
One element, one object — render a complete message row with markdown, reasoning, tool calls, attachments, and action buttons. The keystone of any custom message list.
- Shadow DOM
- 1 event
- User + assistant roles
- Avatar support
- Custom actions
Preview
Section titled “Preview”Set message in JavaScript (objects can’t be HTML attributes) and listen for kai-message-action:
<kai-message id="msg" style="display:block;"></kai-message>
<script type="module"> import '@kitn.ai/ui/elements';
await customElements.whenDefined('kai-message');
const msg = document.getElementById('msg');
msg.message = { id: 'm-1', role: 'assistant', parts: [ { type: 'reasoning', text: 'The user wants X, so I should do Y.' }, { type: 'tool', tool: { type: 'search', state: 'output-available', output: { hits: 3 } } }, { type: 'text', text: "Here's the plan:\n\n```js\nconst kit = useChat();\n```" }, ], actions: ['copy', 'like', 'dislike', 'regenerate'], };
msg.addEventListener('kai-message-action', (e) => { console.log(e.detail.action, e.detail.messageId); });</script>messagetakes the whole object:id,role, an orderedpartsarray, plus optionalactions,avatar, andfeedback. There’s no scalar shortcut for the text; even a single line is a{ type: 'text', text }part.- Markdown is on by default for
role="assistant", off forrole="user". Override with themarkdownboolean. actionsaccepts built-in names ('copy','like','dislike','regenerate','edit') and/or{ id, label, icon?, tooltip? }descriptors. Alternatively, place<kai-action>children in the light DOM.actionsReveal—'always'(default) keeps the action bar visible;'hover'hides it until the row is hovered.
| Child element | Attributes | Text content | Notes |
|---|---|---|---|
| <kai-action> | actioniconidlabeltooltip | Yes | — |
The action row
Section titled “The action row”The built-in copy, like, and dislike actions are wired — the element handles the interaction, you just listen for kai-message-action.
copywrites the message content to the clipboard, flips its icon to a check for a couple of seconds, and raises a “Copied to clipboard” toast.like/dislikemark the chosen vote active (filled,aria-pressed) and animate the other one out, so the row reads as a clear yes/no. Tapping the active vote again clears it and brings both back. Setting a vote raises a “Thanks for your feedback” toast; clearing it is silent.
These survive streaming. The vote and copied state live above the message list, so a fresh messages array per chunk never wipes a user’s vote.
Reading and persisting the vote
Section titled “Reading and persisting the vote”kai-message-action carries a state field for the toggleable votes — 'on' when a vote is set, 'off' when cleared. Copy and custom actions omit it.
msg.addEventListener('kai-message-action', (e) => { const { messageId, action, state } = e.detail; if (action === 'like' || action === 'dislike') { if (state === 'on') saveVote(messageId, action); else clearVote(messageId); }});To re-hydrate a persisted vote (e.g. on history load), set feedback on the message. A controlled feedback wins over the element’s own optimistic state:
msg.message = { id: 'm-1', role: 'assistant', parts: [{ type: 'text', text: '…' }], actions: ['copy', 'like', 'dislike'], feedback: 'like' };Examples
Section titled “Examples”Rich Assistant Message
Section titled “Rich Assistant Message”A full assistant turn: markdown with a fenced code block, a reasoning block, a tool call, an attachment, and built-in actions.
User Message
Section titled “User Message”A plain user turn with no actions. Markdown is off by default for role="user".
Avatar and Custom Action
Section titled “Avatar and Custom Action”An avatar pulled from message.avatar, plus a custom Share action alongside built-in copy and like.
Actions Reveal on Hover
Section titled “Actions Reveal on Hover”actions-reveal="hover" hides the action bar until the row is hovered — cleaner in dense threads.
Compose your own message rows: inject a per-message header or footer, or replace the avatar rail (avatar="none" drops it entirely). These are the keystone of a custom message list.
| Slot | Mode | Purpose |
|---|---|---|
| before-body | inject | A per-message header at the TOP of the body, above reasoning/tools/content: a model-name label, a role + timestamp line. |
| after-body | inject | A row at the BOTTOM of the body, below the action bar: a citation/sources row, a token-cost/latency line. |
avatar· ::part | replace | Replaces the built-in avatar rail with your own node. Use `avatar="none"` to omit the rail and let the body span the full row. |
Styling
Section titled “Styling”Restyle the row, bubble, content, or action bar from outside via ::part.
| Part | Purpose | Example |
|---|---|---|
| row | The message row wrapper (avatar rail + body column). Restyle its gap or alignment from outside. | |
| bubble | The content bubble wrapper. Restyle its background, radius, or padding; for a user message this is the rounded chat bubble. | |
| content | The rendered message text/markdown region (same node as `bubble`). Target it to tune typography from outside. | |
| actions | The action-bar row (copy / like / regenerate …). Restyle its spacing or hide it entirely from outside. | |
| citations | The citation row rendered from the message’s `source` parts: a wrapped row of chips below the bubble, never inside it. Restyle its spacing or hide it entirely from outside. | |
| avatar | Replaces the built-in avatar rail with your own node. Use `avatar="none"` to omit the rail and let the body span the full row. | — |
| Property | Type | Default | Notes |
|---|---|---|---|
| theme | "light" | "dark" | "auto" | 'auto' | Color mode (`auto` follows prefers-color-scheme). |
| message | — | The full message object. Set as a JS property. | |
| role | "user" | "assistant" | 'assistant' | Who is speaking: `'user'` or `'assistant'`. Convenience for simple cases when not passing a `message` object. This is the SEMANTIC role of the message, not an ARIA role. The name collides with the global ARIA `role` attribute, which is why the facade lifts it off the host (see `liftRoleOffHost`). Neither speaker is a valid ARIA role, so a `role="user"` left on `<kai-message>` is a CRITICAL axe `aria-roles` violation. The accessible role lives on the row inside the shadow root instead: `role="article"` plus an `aria-label` naming the speaker, matching the SolidJS `<Message>` component. |
| markdown | boolean | — | Force markdown on/off. Defaults to on for assistant, off for user. |
| proseSize | "xs" | "sm" | "base" | "lg" | 'sm' | Text/markdown sizing for the message body. |
| codeTheme | string | 'github-dark-dimmed' | Shiki theme name used for fenced code blocks in the content. |
| codeHighlight | boolean | true | Disable syntax highlighting for code blocks (no Shiki loads). |
| actionsReveal | "always" | "hover" | 'always' | Whether the action bar is always visible (`'always'`, default) or only revealed on hover of the message row (`'hover'`). |
| avatarSrc | string | — | Convenience avatar image URL (used when `message.avatar` is not set). |
| avatarFallback | string | — | Convenience avatar fallback text (used when `message.avatar` is not set). |
| avatar | string | — | Avatar rail mode. `'none'` omits the avatar rail entirely so the body spans the full row (predictable layout when you never show avatars). Any other value keeps the default behaviour: the built-in avatar when one resolves, or your `slot="avatar"` content when projected (which REPLACES the built-in). |
| cardTypes | Record<string, string> | — | Optional card type -> custom-element tag overrides/additions for `card` parts (merged over the built-ins). Property: `el.cardTypes`. Typed as a plain string map (not the `CardTagMap` alias) so the generated React wrapper inlines it instead of emitting an unresolved named type. |
| cardSchemas | Record<string, object> | — | JSON Schemas for the card types this app renders, keyed by envelope type. The companion of `cardTypes`, which says what DRAWS a card while this says what a VALID one looks like. An OBJECT, so it is a JS property only: `el.cardSchemas = { 'pricing-table': pricingSchema }`, never an attribute. `createCardRegistry(...).validationSchemas` is exactly this shape. Without it the kit validates its own seven built-ins and leaves your own card type, the one your app actually cares about, as the only unchecked thing on screen. A schema here WINS over a built-in of the same name. Typed `Record<string, object>` rather than `Record<string, JsonSchema>` deliberately: an imported `.json` schema widens `"type"` to `string`, and an authored one carries `$schema`/`title`/`description`/`additionalProperties`, so the tighter type would reject both of the normal ways to supply one. |
Events
Section titled “Events”| Event | Detail | Notes |
|---|---|---|
| kai-message-action | | An action button was clicked. `action` is the built-in name or custom id. `state` is present only for the toggleable feedback votes: `'on'` when a like/dislike is set, `'off'` when re-tapped to clear. |
Methods
Section titled “Methods”| Method | Signature | Notes |
|---|---|---|
| copy | (): void | Copy the message content to the clipboard and show the copied check. |
Composed from
Section titled “Composed from”This element wraps these SolidJS components — reach for them directly when you need finer control than the props expose.