Message
One message of the conversation — who wrote it, when, and its content as plain text or as a list of segments.
-
<aparte-chat-bubble>
On this page
<!-- One bubble per message. The viewport creates them for you; written by hand here. -->
<aparte-chat-bubble message-id="u1" data-role="user" name="You" timestamp="2026-10-15T09:41:00"
content="Which transport should I use?"></aparte-chat-bubble>
<aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant" timestamp="2026-10-15T09:41:04"
content="The direct one while you prototype, the backend one before you ship."></aparte-chat-bubble><aparte-chat-bubble> is one message with one role, user or assistant. The
transcript creates one per message in its store; you write the tag
yourself only when your code draws the conversation.
A message holds either plain text (the content attribute, rendered as Markdown when a renderer
is installed) or a list of segments: text, reasoning,
code, tool calls, errors
and views of your own. Segments win when both are set.
Anatomy
Section titled “Anatomy”<aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant" timestamp="2026-10-16T09:41:00"
content="The direct one while you prototype, the backend one before you ship."></aparte-chat-bubble>
<script>
const { aparteGlobalConfig } = window.aparte;
aparteGlobalConfig.setAvatarProvider({ render(role, host) { host.textContent = role === 'user' ? 'P' : 'A'; } });
aparteGlobalConfig.setBubbleActions({ retry: true, feedback: true });
// The second of two versions, so the picker is drawn.
document.querySelector('aparte-chat-bubble').setSiblings(2, 1);
</script>Variants
Section titled “Variants”With avatars
Section titled “With avatars”<!-- In your app: import { aparteGlobalConfig } from '@aparte/core'. -->
<aparte-chat-bubble message-id="u1" data-role="user" name="Paul" content="Can you summarise the release?"></aparte-chat-bubble>
<aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant" content="Two fixes: the drawer is no longer dimmed, and a checked menu item shows its check."></aparte-chat-bubble>
<script>
// The avatar slot is yours to fill: text, an image, a component. It is styled already.
window.aparte.aparteGlobalConfig.setAvatarProvider({
render(role, host) {
host.textContent = role === 'user' ? 'P' : 'A';
},
});
</script>The avatar slot is empty, and hidden, until you fill it with setAvatarProvider(): initials, a
picture, a component. The provider receives the role and the slot, already sized; it can return
a cleanup function for a live component.
Customize
Section titled “Customize”Each part of a message has its own hook instead of a slot: setBubbleShellRenderer for the
whole structure, setAvatarProvider, setAttachmentRenderer, setSiblingNavRenderer, and
registerSegmentRenderer for the content. The colours and spacing are CSS variables, listed
below.
<aparte-chat-bubble streaming=""></aparte-chat-bubble>el.addEventListener('aparte-attachment-preview', (e) => use(e.detail));<aparte-chat-bubble streaming=""></aparte-chat-bubble>The aparte-* tags are typed JSX intrinsics as soon as you import from @aparte/react. A presence attribute takes '', never true — React stringifies it, and streaming={false} would render streaming="false", which hasAttribute reads as on. Events reach you by ref, typed through the DOM.
<template>
<aparte-chat-bubble streaming=""
@aparte-attachment-preview="(e) => use(e.detail)"
></aparte-chat-bubble>
</template>Declared through Vue’s GlobalComponents, so vue-tsc checks the tag in any template. A presence attribute takes '' to set and null to remove, never false.
<aparte-chat-bubble streaming=""
on:aparte-attachment-preview={(e) => use(e.detail)}
></aparte-chat-bubble>Declared through SvelteHTMLElements, so svelte-check covers the attributes and the on: handlers. A presence attribute takes '', never false.
import { AparteChatBubbleDirective } from '@aparte/angular';<aparte-chat-bubble [streaming]="true"
(attachmentPreview)="use($event)"></aparte-chat-bubble>A standalone directive whose selector IS the tag, so the real element sits in the template — @if, @for and content projection all reach it — and no CUSTOM_ELEMENTS_SCHEMA is needed.
Installation and the framework-specific traps: React · Vue · Svelte · Angular.
<aparte-chat-bubble> · class AparteChatBubble
Attributes
| Attribute | Description |
|---|---|
data-role | The message role, user or assistant — the one channel for it, and what the CSS keys off. (role is ARIA’s attribute; this element sets it to article itself and no longer reads a message role from it.) |
content | Plain text content, for a bubble with no segments. |
timestamp | Epoch milliseconds or a date string; a numeric string is read as milliseconds. |
message-id | How streaming and the action bar address this bubble. |
streaming | Hides the action bar and shows the caret while a reply is in flight. |
name | The display name in the header. |
Methods
| Method | Description |
|---|---|
appendToken(chunk: string): void | Append a token chunk (for streaming) |
setContent(content: string): void | Set content directly |
getContent(): string | Get current content |
setSegments(segments: AparteSegment[]): void | Set segments for rich content |
addSegment(segment: AparteSegment): void | Add a segment |
updateSegment(segmentId: string, updates: Partial<AparteSegment>): void | Update a specific segment |
appendToSegment(segmentId: string, content: string): void | Append content to a segment |
getSegments(): AparteSegment[] | Get all segments |
removeSegment(segmentId: string): void | Scoped to DIRECT children on purpose. Segments are appended as direct children of the container, but a descendant query returns the first match in document order — and sanitized model markdown renders inside that same container, with data-* attributes deliberately preserved (they are inert). So a decoy data-segment-id planted in an earlier segment’s prose used to win over the real segment element. Parser ids are unguessable UUIDs, but a tool segment is tool-${toolCallId} and the MODEL chooses that id — so this was reachable, and pointing an update at a decoy left a rejected tool rendering as still-running: a spoof against the human-in-the-loop control. |
setAttachments(attachments: AparteAttachment[]): void | Set attachments (chips shown above message content, user role only) |
setUsage(usage: AparteUsage | null | undefined): void | Set token usage + timing for this message (assistant only). This is the precondition for the info (“i”) action, not the trigger: the button appears only if the app also declared it wants it — aparteGlobalConfig.setBubbleActions({ info: true }) — because the stats popover it opens (aparte-message-info) is the app’s, and core has none. Without usage there is nothing to show, so the button never renders either way. |
setSiblings(count: number, index: number): void | Update the branch picker UI for tree-based navigation. The viewport calls this after a branch switch or re-render. Prev/Next clicks dispatch aparte-branch-navigate (bubbles: true) so the viewport can handle the actual tree switch. |
updateMessage(updates: Partial<AparteMessage>): void | Atomic update for the message |
setTranscriptBusy(busy: boolean): void |
Events
| Event | Type | Description |
|---|---|---|
aparte-attachment-preview | CustomEvent<AparteAttachmentPreviewEventDetail> | An attached image was clicked, asking the app to open it full-size. |
aparte-action | CustomEvent<AparteActionEventDetail> | A custom action-bar button was pressed. |
aparte-retry | CustomEvent<AparteRetryEventDetail> | Retry was pressed; the host forks the turn. |
aparte-feedback | CustomEvent<AparteFeedbackEventDetail> | Thumbs up or down. |
aparte-message-info | CustomEvent<AparteMessageInfoEventDetail> | The info affordance was pressed. |
aparte-edit | CustomEvent<AparteEditEventDetail> | An edit was saved. |
aparte-branch-navigate | CustomEvent<AparteBranchNavigateEventDetail> | The ‹1/2› picker moved between sibling versions. |
aparte-link-click | CustomEvent<AparteLinkClickEventDetail> | A link in the message body is about to be followed. Cancelable: preventDefault() keeps the browser from navigating, so a host can route the link itself. |
CSS variables
| Variable | Default | Description |
|---|---|---|
--aparte-message-gap | var(--aparte-space-6) | Gap between the avatar column and the body (the viewport reuses it between messages). |
--aparte-message-padding-block | var(--aparte-space-8) | Vertical padding of one message row. (The composite --aparte-message-padding is gone: joined on :root, it froze both parts, and the narrow-container override of them never reached the row.) |
--aparte-message-padding-inline | var(--aparte-space-6) | Horizontal padding of one message row. |
--aparte-message-max-width | 800px | Width of the centred message row. |
--aparte-message-content-radius | var(--aparte-radius-bubble) | Radius of the painted content box. |
--aparte-message-content-padding | var(--aparte-space-5) var(--aparte-space-7) | Padding of the USER box only; the assistant’s content is plain full-width prose. |
--aparte-message-content-bg-user | — | Background of the user box: a wash of --aparte-primary over --aparte-surface-1, derived in theme.css so a rebrand moves it (declare it to override). |
--aparte-message-content-bg-assistant | transparent | Background of the assistant box — transparent on purpose (AI-chat convention, not messaging). |
--aparte-message-content-text-user | var(--aparte-text) | Text colour inside the user box. |
--aparte-message-content-text-assistant | var(--aparte-text) | Text colour inside the assistant box. |
--aparte-avatar-size | 32px | Square size of the avatar slot. |
--aparte-avatar-radius-ratio | 0.28 | Avatar corner radius, as a fraction of its size (the corner follows the ramp; 0.28 × 32px is the 9px it used to be). |
--aparte-avatar-initials-ratio | 0.34 | Size of the initial, as a fraction of the avatar’s size. |
--aparte-avatar-bg-user | var(--aparte-primary) | Avatar background, user role. |
--aparte-avatar-text-user | var(--aparte-text-inverse) | Avatar text colour, user role. |
--aparte-avatar-bg-assistant | var(--aparte-surface-3) | Avatar background, assistant role. |
--aparte-avatar-text-assistant | var(--aparte-text) | Avatar text colour, assistant role. |
--aparte-avatar-image-user | none | background-image for the user avatar — a logo with no AvatarProvider and no JS. |
--aparte-avatar-image-assistant | none | background-image for the assistant avatar. |
--aparte-avatar-image-size | 90% | background-size for both avatar images. |
--aparte-name-font-size | var(--aparte-font-size-md) | Sender name in the header. |
--aparte-name-color | var(--aparte-text) | Sender name colour. |
--aparte-timestamp-font-size | var(--aparte-font-size-sm) | Timestamp in the header. |
--aparte-timestamp-color | var(--aparte-text-muted) | Timestamp colour. |
--aparte-content-font-size | var(--aparte-font-size-base) | Body type size, applied to both the plain-content and the segments container. |
--aparte-content-color | var(--aparte-text) | Body text colour. |
--aparte-content-line-height | var(--aparte-line-height-loose) | Body line height. |
--aparte-attachments-max-height | 140px | Cap on the sent-attachment strip; past it the strip scrolls instead of growing. |
--aparte-attachment-image-size | 72px | Tile size in the strip. The strip re-declares the global 72px down to 40px, since these are thumbnails inside a conversation. |
--aparte-thumb-radius | var(--aparte-radius-lg) | Attachment tile radius (shared with the composer’s preview tiles). |
--aparte-thumb-name-color | #ffffff | Filename overlaid on a tile. |
--aparte-thumb-name-scrim | — | Gradient behind that filename, so it stays legible over any image. |
--aparte-thumb-name-padding | 14px 5px 4px | Padding of the filename overlay. |
--aparte-action-bar-gap | var(--aparte-space-2) | Gap between action buttons (and between the footer’s two regions). |
--aparte-action-bar-btn-size | var(--aparte-btn-size-sm) | Square size of an action button (the recipe’s small step); also the footer’s reserved height. |
--aparte-action-bar-btn-color | var(--aparte-text-muted) | Action icon colour at rest. |
--aparte-action-bar-btn-hover-bg | var(--aparte-surface-2) | Action button hover background (the branch arrows reuse it). |
--aparte-action-bar-btn-hover-color | var(--aparte-text) | Action icon colour on hover. |
--aparte-branch-picker-gap | var(--aparte-space-2) | Gap between the arrows and the position label. |
--aparte-branch-picker-btn-color | var(--aparte-text-muted) | Arrow colour at rest. |
--aparte-branch-picker-btn-hover-color | var(--aparte-text) | Arrow colour on hover (a disabled arrow is dimmed instead). |
--aparte-branch-picker-label-size | var(--aparte-font-size-sm) | Type size of the position label. |
--aparte-branch-picker-label-color | var(--aparte-text-muted) | Colour of the position label. |
--aparte-branch-picker-label-min-width | 32px | Reserved label width, so 9 / 9 growing to 10 / 12 does not shift the arrows. |
--aparte-waiting-height | 1.5em | Min height of the waiting region, so the first token does not jump the layout. |
--aparte-waiting-dot-gap | var(--aparte-space-2) | Gap between the three waiting dots. |
--aparte-status-dot-size | 6px | Diameter of a waiting dot (shared with the status indicator). |
--aparte-status-color | var(--aparte-text-muted) | Colour of the waiting dots (shared with the status indicator). |
--aparte-error-solid | #dc2626 | Ring drawn around the avatar while data-error is set. The error CARD itself belongs to the error segment renderer. |
Accessibility
Section titled “Accessibility”- Each message is a
role="article"named after its author, so a screen reader can move from one message to the next. - A message with nothing to show (a tool with no output, a turn stopped before its first token) hides its whole row instead of leaving a name and a time over nothing.
Related
Section titled “Related”- Message actions — copy, retry, edit, feedback.
- Branches — the
‹1/2›picker between versions of a reply. - Attachments — the files a message carries.