Skip to content
kitn AI/UI

Message

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

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>
  • message takes the whole object: id, role, an ordered parts array, plus optional actions, avatar, and feedback. 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 for role="user". Override with the markdown boolean.
  • actions accepts 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 elementAttributesText contentNotes
actioniconidlabeltooltip
Yes

The built-in copy, like, and dislike actions are wired — the element handles the interaction, you just listen for kai-message-action.

  • copy writes 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 / dislike mark 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.

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' };

A full assistant turn: markdown with a fenced code block, a reasoning block, a tool call, an attachment, and built-in actions.

A plain user turn with no actions. Markdown is off by default for role="user".

An avatar pulled from message.avatar, plus a custom Share action alongside built-in copy and like.

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.

SlotModePurpose
injectA per-message header at the TOP of the body, above reasoning/tools/content: a model-name label, a role + timestamp line.
injectA row at the BOTTOM of the body, below the action bar: a citation/sources row, a token-cost/latency line.
replaceReplaces the built-in avatar rail with your own node. Use `avatar="none"` to omit the rail and let the body span the full row.

Restyle the row, bubble, content, or action bar from outside via ::part.

PartPurposeExample
The message row wrapper (avatar rail + body column). Restyle its gap or alignment from outside.
kai-message::part(row) { gap: 0.75rem }
The content bubble wrapper. Restyle its background, radius, or padding; for a user message this is the rounded chat bubble.
kai-message::part(bubble) { background: var(--color-primary); color: var(--color-primary-foreground) }
The rendered message text/markdown region (same node as `bubble`). Target it to tune typography from outside.
kai-message::part(content) { font-size: 0.9375rem }
The action-bar row (copy / like / regenerate …). Restyle its spacing or hide it entirely from outside.
kai-message::part(actions) { gap: 0.25rem }
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.
kai-message::part(citations) { gap: 0.5rem }
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.
PropertyTypeDefaultNotes
theme'auto'Color mode (`auto` follows prefers-color-scheme).
messageThe full message object. Set as a JS property.
role'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.
markdownForce markdown on/off. Defaults to on for assistant, off for user.
proseSize'sm'Text/markdown sizing for the message body.
codeTheme'github-dark-dimmed'Shiki theme name used for fenced code blocks in the content.
codeHighlighttrueDisable syntax highlighting for code blocks (no Shiki loads).
actionsReveal'always'Whether the action bar is always visible (`'always'`, default) or only revealed on hover of the message row (`'hover'`).
avatarSrcConvenience avatar image URL (used when `message.avatar` is not set).
avatarFallbackConvenience avatar fallback text (used when `message.avatar` is not set).
avatarAvatar 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).
cardTypesOptional 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.
cardSchemasJSON 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.
EventDetailNotes
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.
MethodSignatureNotes
(): voidCopy the message content to the clipboard and show the copied check.

This element wraps these SolidJS components — reach for them directly when you need finer control than the props expose.

MessageMessageAvatarMessageBody