# MessageBubble

One turn of a chat transcript — a role-tinted surface carrying the turn's text, its slotted content and its reactions.

```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;

    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

```slint
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](/docs/components/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](/docs/components/message-row) 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.

```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: 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.

```slint
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](/docs/components/button), and `reaction-activated(index)` reports which of them was taken.

```slint
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.

```slint
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.

```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: 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.
- **`interactive` makes it a button**, named by `action-label` — which defaults to `text` and 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](/docs/components/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.** `destructive` says a send failed with its tint; the words that say so belong in the turn’s own `text`, where assistive technology will find them.
