MessageBubble
One turn of a chat transcript — a role-tinted surface carrying the turn's text, its slotted content and its reactions.
import { MessageBubble, MessageRole } from "@glint/components/message-bubble.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 520px; height: 260px; background: Tokens.color-background;
VerticalLayout { padding: 24px; spacing: 12px; alignment: center;
MessageBubble { role: MessageRole.user; text: "Can you refactor the parser to take the tokens by reference?"; }
MessageBubble { role: MessageRole.agent; text: "Done — the borrow now lives as long as the arena, so nothing is cloned on the way in."; reactions: ["Thanks 2", "Nice 1"]; } }}Usage
import { MessageBubble, MessageRole } from "@glint/components/message-bubble.slint";
export component AppWindow inherits Window { VerticalLayout { spacing: 12px;
MessageBubble { role: MessageRole.user; text: "Refactor this."; }
// `text` is the common case — one wrapped paragraph. Anything else is // slotted, and stacks under it. reply := MessageBubble { role: MessageRole.agent; text: "Done — here's the diff.";
Rectangle { height: 80px; border-radius: 6px; background: reply.surface-color; } } }}A bubble is a full-width transcript row. The row spans the transcript and
the surface inside it does not: role decides which side that surface hugs —
user right, agent left — so a transcript is a VerticalLayout of bubbles and
nothing has to align anything. max-surface-width is how wide the surface may
grow before its text wraps.
text covers a single wrapped paragraph. @children carries everything else
— a code block, a table, a custom layout — stacked under it inside the
surface’s own padding. Slotted content that has to sit on the surface binds
the three colors the bubble resolves (surface-color, edge-color,
foreground) and the two lengths that carry its rhythm (padding-l,
gap-l), the same way Card’s parts do. That is what
keeps a slotted code block readable when the reader switches palette or theme
mode.
The bubble is inline transcript content rather than an overlay, so it sits at elevation e2 — it is in the transcript, not floating over it.
Examples
Roles
role is who authored the turn. It picks the side the surface hugs and, while
no variant overrules it, the tint too. It carries no behavior of its own.
MessageRow takes the same property rather than
an alignment of its own, so the frame and the surface inside it cannot
disagree about which side a turn is on.
import { MessageBubble, MessageRole } from "@glint/components/message-bubble.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 520px; height: 220px; background: Tokens.color-background;
VerticalLayout { padding: 24px; spacing: 12px; alignment: center;
MessageBubble { role: MessageRole.agent; text: "The agent's turn keeps to the leading side."; }
MessageBubble { role: MessageRole.user; text: "The user's turn keeps to the trailing side."; } }}Variants
variant is a second axis, chosen independently of who authored the turn: a
failed send is destructive whichever side it hugs, and an unframed answer is a
ghost. default means “the role’s own tint”, so a transcript that never names
a variant is unchanged (ADR-0034).
outline and ghost draw no fill, so a bubble in either sits directly on
whatever the transcript’s background is.
import { MessageBubble, MessageBubbleVariant, MessageRole } from "@glint/components/message-bubble.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 520px; height: 420px; background: Tokens.color-background;
VerticalLayout { padding: 24px; spacing: 10px; alignment: center;
MessageBubble { role: MessageRole.agent; variant: MessageBubbleVariant.default; text: "default — the role's own tint"; } MessageBubble { role: MessageRole.agent; variant: MessageBubbleVariant.secondary; text: "secondary"; } MessageBubble { role: MessageRole.agent; variant: MessageBubbleVariant.muted; text: "muted"; } MessageBubble { role: MessageRole.agent; variant: MessageBubbleVariant.tinted; text: "tinted"; } MessageBubble { role: MessageRole.agent; variant: MessageBubbleVariant.outline; text: "outline — no fill, an edge"; } MessageBubble { role: MessageRole.agent; variant: MessageBubbleVariant.ghost; text: "ghost — no fill, no edge"; } MessageBubble { role: MessageRole.user; variant: MessageBubbleVariant.destructive; text: "destructive — this one failed to send"; } }}Reactions
Reactions are a model rather than slotted content: the one @children is
spent on the turn, and slotted content is laid out inside the surface’s
padding where it could not overhang the edge a reaction anchors to (ADR-0034).
So the strip is [string] plus the two properties that place it —
reactions-side picks the edge it straddles, reactions-align the end of
that edge it gathers at.
Each label is the consumer’s to compose — ”👍 3” in most chat applications,
words in the previews here: the bubble counts nothing and knows nobody, and an
emoji is only as available as the fonts the platform has. Each pill is a
Button, and reaction-activated(index) reports
which of them was taken.
import { MessageBubble, MessageReactionAlign, MessageReactionSide, MessageRole } from "@glint/components/message-bubble.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 520px; height: 300px; background: Tokens.color-background;
in-out property <string> taken: "No reaction taken yet.";
VerticalLayout { padding: 24px; spacing: 28px; alignment: center;
MessageBubble { role: MessageRole.agent; text: "Bottom edge, gathered at the trailing end — the default."; reactions: ["Thanks 4", "Ship it 1"]; reactions-side: MessageReactionSide.bottom; reactions-align: MessageReactionAlign.end; reaction-activated(index) => { root.taken = "Took reaction " + index + " on the agent's turn."; } }
MessageBubble { role: MessageRole.user; text: "Top edge, gathered at the leading end."; reactions: ["Nice 2"]; reactions-side: MessageReactionSide.top; reactions-align: MessageReactionAlign.start; reaction-activated(index) => { root.taken = "Took reaction " + index + " on the user's turn."; } }
Text { text: root.taken; color: Tokens.color-muted-foreground; font-size: Tokens.typography-body-sm-size; } }}A bubble that is a control
interactive makes the whole surface the control — the shape behind “open
this turn”, “quote it”, “see the tool call behind it”. The control layer sits
under the content, so a button the consumer slotted in still takes its own
press and a press nothing above it wanted falls through to the surface: the
trigger and the actions never trap each other.
A bubble that is not interactive has no control layer at all, so there is nothing for Tab to land on.
import { Button, ButtonSize, ButtonVariant } from "@glint/components/button.slint";import { MessageBubble, MessageRole } from "@glint/components/message-bubble.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 520px; height: 260px; background: Tokens.color-background;
in-out property <string> last: "Nothing taken yet.";
VerticalLayout { padding: 24px; spacing: 16px; alignment: center;
bubble := MessageBubble { role: MessageRole.agent; text: "Ran 3 tools. Open the turn to see them."; interactive: true; action-label: "Open the tool calls behind this turn"; clicked => { root.last = "Opened the turn."; }
HorizontalLayout { alignment: start; Button { variant: ButtonVariant.secondary; size: ButtonSize.xs; text: "Copy"; clicked => { root.last = "Copied the turn — the surface was not opened."; } } } }
Text { text: root.last; color: Tokens.color-muted-foreground; font-size: Tokens.typography-body-sm-size; } }}Slotted content on the surface
The three colors the bubble resolved are published, so content that has to
sit on the surface reads them instead of restating the two axes. A code block
under a user turn is on color-primary; the same block under an agent turn is
on color-card, and neither call site had to know which.
import { MessageBubble, MessageRole } from "@glint/components/message-bubble.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 520px; height: 300px; background: Tokens.color-background;
VerticalLayout { padding: 24px; spacing: 12px; alignment: center;
reply := MessageBubble { role: MessageRole.agent; text: "Here's the change:"; max-surface-width: 400px;
Rectangle { border-radius: Tokens.radius-md; border-width: 1px; border-color: Tokens.color-border-hairline; background: Tokens.color-surface-1;
VerticalLayout { padding: reply.gap-l;
Text { text: "- fn parse(tokens: Vec<Token>)\n+ fn parse(tokens: &[Token])"; color: reply.foreground; font-family: "monospace"; font-size: Tokens.typography-body-sm-size; wrap: no-wrap; } } } } }}API Reference
Properties
| Property | Type | Default | Description |
|---|---|---|---|
role | in MessageRole | MessageRole.agent | Who authored the turn — user hugs the right edge, agent the left. |
text | in string | no default | The turn's message text; empty string omits it and leaves the slot as the only content. |
max-surface-width | in length | 480px | Widest the surface may grow before its text wraps — the row itself still spans the transcript. |
variant | in MessageBubbleVariant | MessageBubbleVariant.default | How the surface is painted. default is the role's own tint. |
reactions | in [string] | no default | The reactions carried on the surface's edge, each a label the consumer has already composed ("👍 3"). Empty draws no strip. |
reactions-side | in MessageReactionSide | MessageReactionSide.bottom | Which edge of the surface the strip straddles, and which end of it the reactions gather at. |
reactions-align | in MessageReactionAlign | MessageReactionAlign.end | |
interactive | in bool | false | Makes the whole surface the control: hover, press and the focus ring apply to the bubble, not to something nested inside it. A bubble that is not interactive has nothing for Tab to land on. |
action-label | in string | root.text | What assistive technology announces the surface as while it is a control. The turn's own text says it for the common case; a bubble whose content is all slotted has to be told. |
surface-color | out color | no default | Surface fill, for slotted content that has to sit on it. The role decides it while the variant is default; every other variant is the consumer overruling the role, which is the whole point of the axis. |
edge-color | out color | no default | The hairline around the fill. A filled surface is its own edge, and the hairline tokens are page-background overlays that would vanish against it in either theme mode — so only the surfaces that need lifting off the background get one. |
foreground | out color | no default | Text color for slotted content, resolved from the same two axes. |
padding-l | out length | no default | Surface padding, for slotted content that draws to the bubble edge. |
gap-l | out length | no default | Vertical rhythm between the message text and slotted content. |
Callbacks
| Callback | Description |
|---|---|
clicked() | Fired when an interactive surface is activated, by pointer, by Enter or Space, or through the accessible default action. |
reaction-activated(int) | Fired with the place in reactions of the one that was activated. |
Enums
| Enum | Values |
|---|---|
MessageRole | user, agent |
MessageBubbleVariant | default, secondary, muted, tinted, outline, ghost, destructive |
MessageReactionSide | top, bottom |
MessageReactionAlign | start, end |
Accessibility
- An inert bubble is text, not a control. The turn’s own text node is what a screen reader reads; nothing announces the surface, and Tab has nothing to land on. That is deliberate — an affordance that does nothing must not collect a tab stop.
interactivemakes it a button, named byaction-label— which defaults totextand has to be set where the turn’s content is all slotted, or the surface reads as an anonymous button.- One activation path. The pointer, Enter, Space and
the accessible default action all land on the same
clicked, so they cannot drift apart. - The control layer is under the content, so a slotted button keeps its own press and its own place in the tab order.
- A reaction is a control, not a decoration. Each pill is a real
Button: it is announced, it is reachable from the
keyboard, and it reports its place in
reactions. - Keyboard-only focus ring, around the surface when the keyboard put the focus there and not when a click did.
- Color is never the only channel.
destructivesays a send failed with its tint; the words that say so belong in the turn’s owntext, where assistive technology will find them.