---
title: Conversation
description: Stick-to-bottom chat transcript scroller with follow-output and jump-to-latest
visibility: guest
draft: false
---

# Conversation

The scroll container a live chat needs. A plain `overflow-y-auto` div gets a
conversation wrong in three ways — Conversation fixes all three:

1. **Follow-output** — it pins the viewport to the newest message as messages
   (or streamed tokens) arrive, but **only while the reader is already at the
   bottom**. The moment they scroll up to read back, the pin releases, so their
   position is never yanked away mid-sentence.
2. **Anchor-preserve** — when you prepend older history, the message they were
   reading stays exactly where it is instead of jumping.
3. **Jump-to-latest** — while they are scrolled away, an unread counter appears;
   one click returns them to the newest message.

It watches the **DOM**, not a JavaScript data source — so `wire:poll`,
`wire:stream`, and Echo broadcasts all drive it with no extra wiring. Pair it
with [Message](/components/message) for the rows.

## Basic Usage

Give it messages and a height. That is the whole setup — follow-output is on.

:::preview{title="A chat transcript"}
<div style="width: 24rem; max-width: 100%; margin-inline: auto;">
    <x-wirekit::conversation max-height="16rem" label="Support thread">
        <x-wirekit::stack gap="md">
            <x-wirekit::message :author="['name' => 'Ada']" timestamp="2026-07-17 09:14">Morning — is the export API rate-limited?</x-wirekit::message>
            <x-wirekit::message :author="['name' => 'You']" side="right" timestamp="2026-07-17 09:15">Yes, 60 requests a minute per token.</x-wirekit::message>
            <x-wirekit::message :author="['name' => 'Ada']" timestamp="2026-07-17 09:16">Perfect. Does a 429 include a Retry-After header?</x-wirekit::message>
            <x-wirekit::message :author="['name' => 'You']" side="right" timestamp="2026-07-17 09:17">It does — always in seconds.</x-wirekit::message>
            <x-wirekit::message :author="['name' => 'Ada']" timestamp="2026-07-17 09:18">That is everything I needed, thanks!</x-wirekit::message>
            <x-wirekit::message :author="['name' => 'You']" side="right" timestamp="2026-07-17 09:19">Any time.</x-wirekit::message>
        </x-wirekit::stack>
    </x-wirekit::conversation>
</div>
:::

:::source{language="blade"}
<x-wirekit::conversation max-height="16rem" label="Support thread">
    <x-wirekit::stack gap="md">
        <x-wirekit::message :author="['name' => 'Ada']" timestamp="2026-07-17 09:14">Morning — is the export API rate-limited?</x-wirekit::message>
        <x-wirekit::message :author="['name' => 'You']" side="right" timestamp="2026-07-17 09:15">Yes, 60 requests a minute per token.</x-wirekit::message>
        <x-wirekit::message :author="['name' => 'Ada']" timestamp="2026-07-17 09:16">Perfect. Does a 429 include a Retry-After header?</x-wirekit::message>
        <x-wirekit::message :author="['name' => 'You']" side="right" timestamp="2026-07-17 09:17">It does — always in seconds.</x-wirekit::message>
        <x-wirekit::message :author="['name' => 'Ada']" timestamp="2026-07-17 09:18">That is everything I needed, thanks!</x-wirekit::message>
        <x-wirekit::message :author="['name' => 'You']" side="right" timestamp="2026-07-17 09:19">Any time.</x-wirekit::message>
    </x-wirekit::stack>
</x-wirekit::conversation>
:::

## Streaming footer

The `footer` slot is pinned to the end of the transcript and **participates in
follow-output** — so a typing indicator stays glued to the bottom while the
reader is following along.

:::preview{title="A pinned streaming footer"}
<div style="width: 24rem; max-width: 100%; margin-inline: auto;">
    <x-wirekit::conversation max-height="14rem" label="Assistant thread">
        <x-wirekit::stack gap="md">
            <x-wirekit::message :author="['name' => 'You']" side="right" timestamp="2026-07-17 11:02">Summarize the release notes.</x-wirekit::message>
            <x-wirekit::message :author="['name' => 'Assistant']" timestamp="2026-07-17 11:02">Sure — pulling the last three versions now.</x-wirekit::message>
        </x-wirekit::stack>
        <x-slot:footer>
            <x-wirekit::row gap="sm" align="center">
                <x-wirekit::spinner size="sm" intent="accent" />
                <x-wirekit::text size="sm" variant="muted">Assistant is thinking…</x-wirekit::text>
            </x-wirekit::row>
        </x-slot:footer>
    </x-wirekit::conversation>
</div>
:::

:::source{language="blade"}
<x-wirekit::conversation max-height="14rem" label="Assistant thread">
    <x-wirekit::stack gap="md">
        <x-wirekit::message :author="['name' => 'You']" side="right" timestamp="2026-07-17 11:02">Summarize the release notes.</x-wirekit::message>
        <x-wirekit::message :author="['name' => 'Assistant']" timestamp="2026-07-17 11:02">Sure — pulling the last three versions now.</x-wirekit::message>
    </x-wirekit::stack>
    <x-slot:footer>
        <x-wirekit::row gap="sm" align="center">
            <x-wirekit::spinner size="sm" intent="accent" />
            <x-wirekit::text size="sm" variant="muted">Assistant is thinking…</x-wirekit::text>
        </x-wirekit::row>
    </x-slot:footer>
</x-wirekit::conversation>
:::

## Loading older history

When the reader scrolls near the top, Conversation dispatches
`conversation-reached-top`. Handle it with `x-on` → a Livewire method; the
scroller preserves the reader's position once your history lands.

```blade
{{-- 1. loadOlder() prepends messages; the scroller keeps the reader in place --}}
<x-wirekit::conversation
    @conversation-reached-top="$wire.loadOlder()"
    max-height="24rem"
>
    {{-- 2. A spinner while the prepend is in flight --}}
    <div wire:loading wire:target="loadOlder">
        <x-wirekit::spinner size="sm" />
    </div>

    @foreach($messages as $message)
        <x-wirekit::message :author="$message->author" :timestamp="$message->created_at">
            {{ $message->body }}
        </x-wirekit::message>
    @endforeach
</x-wirekit::conversation>
```

## Livewire streaming

Because the scroller watches the DOM, `wire:stream` needs no extra wiring — the
tokens land, the transcript follows:

```blade
{{-- 1. Streamed tokens append into this bubble; follow-output tracks them --}}
<x-wirekit::conversation max-height="24rem">
    <x-wirekit::message :author="['name' => 'Assistant']">
        <span wire:stream="answer">{{ $answer }}</span>
    </x-wirekit::message>
</x-wirekit::conversation>
```

## Deep-linking to a message

Tag rows with `data-wk-message-id` and call `scrollToMessage(id)` to jump to a
quoted message:

```blade
{{-- 1. Tag the row --}}
<x-wirekit::message data-wk-message-id="{{ $message->id }}">…</x-wirekit::message>

{{-- 2. Jump to it from a reply-quote --}}
<button type="button" @click="scrollToMessage('{{ $quoted->id }}')">View quote</button>
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | string | `'Conversation'` | Accessible name of the transcript log region |
| `maxHeight` | string | `'24rem'` | Height cap of the scroll viewport — a transcript needs a bounded height |
| `threshold` | int\|null | `null` | px tolerance for "at bottom" (plugin default `24`) |
| `scope` | string\|null | `null` | Scoped personalization name |

## Slots

| Slot | Description |
| --- | --- |
| default | The message rows |
| `footer` | Pinned to the end of the transcript; participates in follow-output (typing indicator) |

## Events

| Event | When |
| --- | --- |
| `conversation-reached-top` | The reader scrolled near the top — load older history |

## Accessibility

- The viewport is `role="log"` with `aria-live="polite"` — the semantic role for
  a transcript, so new messages are announced politely without interrupting.
- **The live region is always present in the DOM.** A region that appears at the
  same moment its text does is inert to assistive technology, so a `wire:stream`
  swap inside a conditionally-rendered region would never announce. This one is
  rendered up front.
- The viewport carries `tabindex="0"` + `aria-label`, so the scroll region is
  reachable and operable by keyboard (WCAG 2.1.1), with a visible focus ring.
- The jump-to-latest control is a real `<button>` and announces the unread count
  ("Jump to latest, 3 new") — not just "there is more".
- `scrollToBottom()` / `scrollToMessage()` respect `prefers-reduced-motion`:
  they jump instantly instead of smooth-scrolling.

## Keyboard Interaction

| Key | Action |
| --- | --- |
| <kbd>Tab</kbd> | Focus the transcript region |
| <kbd>↑</kbd> / <kbd>↓</kbd> | Scroll the transcript (native) |
| <kbd>Page Up</kbd> / <kbd>Page Down</kbd> | Scroll by a viewport (native) |
| <kbd>Home</kbd> / <kbd>End</kbd> | Jump to the start / end (native) |

## Pitfalls

- **Give it a height.** Without a `maxHeight` (or a height class) there is
  nothing to scroll and follow-output has no meaning.
- **Do not scroll it yourself.** Calling `scrollTop` from your own code fights
  the pin. Use `scrollToBottom()` / `scrollToMessage()`.
- **Prepend, do not replace, for history.** Replacing the whole list drops the
  anchor and the reader loses their place.

## Design Tokens

| Element | Token |
| --- | --- |
| Scrollbar | `--color-wk-scrollbar-*` (via `.wk-scrollbar`) |
| Focus ring | `--color-wk-ring` |
| Jump control shadow | `--shadow-wk-md` |
| Jump control offset | `--space-wk-sm` |

## Further Reading

- [WAI-ARIA: `log` role](https://www.w3.org/TR/wai-aria-1.2/#log)
- [WCAG 2.1.1: Keyboard](https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html)
- [MDN: `ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver)
