Skip to main content
WireKit
Copy for LLM

Stream

The <x-wirekit::stream> component renders streaming text output — an LLM response, a live log — from a Server-Sent Events endpoint. It exists so you never re-write the three parts that are genuinely hard to get right:

  • Accessibility. A growing aria-live="polite" field is unusable — a screen reader re-reads the whole thing on every token. Stream announces that a response is generating once, then the result once, and keeps the visible output out of the live region so it never floods.
  • Reduced motion. A token-by-token build is motion. Under prefers-reduced-motion: reduce the tokens are buffered and revealed as one block.
  • Abort & error. A half-streamed response whose connection drops has a defined terminal state (aborted / failed), not a silent freeze.

You supply the URL and render the text; the library owns the state machine.

Usage

Point url at an SSE endpoint that emits text tokens and closes with a done signal ([DONE] by default). The stream starts on load and appends tokens as they arrive.

<x-wirekit::stream url="/chat/{{ $conversation->id }}/stream" />

The demos on this page use simulate — they type a fixed string out token by token from a local timer, with no live endpoint, so you can watch the streaming behavior right here. Use the ↻ Replay control to run it again. In your app you pass url instead; simulate is for demos and typewriter effects.

Watch it stream

With controls

The default slot renders inside the component's Alpine scope, so you can add Stop and Retry controls that call stop() / restart() and react to status. Bind your control buttons with WireKit's own components.

Stream with stop / retry controls

Announcement mode

By default (announce="result") the settled response is announced once in full. For long outputs where reading the whole thing aloud is too much, use announce="status" to announce only that the response is ready — the text stays on screen for the reader to navigate to.

There is a third value, announce="none", and it exists for one situation: your page already has its own status region for this response. It renders no role="status" region and makes no progress or completion announcement. Pair it with the output slot, which replaces the visible block for the same reason — two regions announcing the same completion is not twice the accessibility, it is a screen reader saying it twice with no way to tell which one is the real one.

The failure announcement stays, and that is deliberate. none hands you the progress announcements, not the error one: the role="alert" region below the output is rendered whatever announce says, and a failed stream still reaches a screen reader through it. Silencing a failure is a different decision from silencing a completion, and a prop about routine progress should not make it quietly. If your own region also announces failures, use the error slot to render your own copy there instead of building a second one.

announce="none" hands you the announcement, it does not remove it With none, nothing tells a screen-reader user that the response finished. If your own region does not announce completion, the stream ends silently for anyone not watching the screen. Use it only when you have already built that region.

Status-only announcement

Seeded output (SSR / resume)

Pass initialText to render a completed response immediately — resume a finished conversation on a server render, or show a static example — without opening a stream. Combine it with :auto-start="false" so the component stays idle and simply displays the seeded text.

A seeded, already-complete response

Livewire integration

In your app, point url at a Livewire-driven route that returns text/event-stream and writes tokens as your model produces them; drive the trigger from Livewire and let the component own the rendering and accessibility:

{{-- 1. A route that streams tokens as Server-Sent Events, ending with [DONE]. --}}
<x-wirekit::stream url="/chat/{{ $conversation->id }}/stream" />

Choosing a transport

source decides where the tokens come from. The state machine — one "generating" announcement, one result announcement, reduced-motion buffering, a defined terminal state — is identical in all three.

source Transport Use it when
sse (default) The browser's EventSource Your endpoint streams over GET
fetch fetch() + a streamed response body The request carries a payload
manual None — you feed it You already have a transport (Reverb/Echo, WebSocket, Livewire)

fetch — when the request is the payload

EventSource is GET-only and body-less by specification. An LLM call carries the prompt, the options and the model in its body — none of which belongs in a URL, whether for length, encoding, or the simple fact that user text has no business in server logs, referrers and browser history.

<x-wirekit::stream
    source="fetch"
    :url="route('translate')"
    method="post"
    :body="['text' => $source, 'target' => 'de']"
    :headers="['X-CSRF-TOKEN' => csrf_token()]"
/>

Your endpoint still writes SSE framingdata: lines separated by a blank line, ending with [DONE] — it just does so over a POST response. That is what OpenAI-, Anthropic- and compatible APIs do, and any proxy you put in front.

A failed or dropped request is a terminal state, reported to the reader. It is never retried: a token stream is billed and not idempotent, so a silent replay would spend money on a request that already ran. (EventSource reconnects by itself; this path deliberately does not.)

A body that changes every run

The body prop is the payload the component is mounted with. Real requests are rebuilt each time — the text the reader just typed, the options they just picked — so pass the payload to start() instead:

{{-- 1. Give the component a reference so your controls can reach it. --}}
<div x-data="{ text: '', target: 'de' }">
    <x-wirekit::textarea x-model="text" />

    {{-- 2. start(payload) sets the body for THIS run. Omit it and the mounted
            body is reused unchanged. --}}
    <x-wirekit::button x-on:click="$refs.translation.start({ text, target })">
        Translate
    </x-wirekit::button>

    <x-wirekit::stream x-ref="translation" source="fetch" :url="route('translate')" :auto-start="false" />
</div>

setBody(payload) does the same without starting, for a form that assembles its request before the reader submits it.

Named events

Real endpoints rarely send one kind of frame. A translation stream sends its model and token budget first, then the tokens, then possibly a refusal — SSE names each one, and Laravel's own helper frames them event: <name> followed by data:.

Name the event that carries text and the component keeps the rest out of the output:

<x-wirekit::stream source="fetch" event-name="token" :url="route('translate')" />

Every named frame is dispatched as a wirekit-stream-event on the element, so the frames that are not text are yours to act on. detail.json is the decoded payload when the frame carries JSON:

// 1. Listen on the element or any ancestor — the event bubbles.
document.addEventListener('wirekit-stream-event', (e) => {
    // 2. `name` is the stream's own name prop; `event` is the SSE event name.
    if (e.detail.event === 'refused') {
        showPolicyNotice(e.detail.json.reason);
    }
});

Without event-name, nothing is filtered — every frame's data becomes text, which is what a single-event stream needs.

manual — you own the transport

Laravel's own realtime stack is Reverb + Echo over WebSocket, not SSE. In manual mode the component opens nothing and you drive it — which means you keep the accessible live region and the terminal states without adopting a transport you do not use.

<x-wirekit::stream source="manual" name="advisor-1" />
// 1. Your transport — anything at all. Here: Laravel Echo.
Echo.private(`turn.${turnId}`)
    .listen('.voice.chunk', (e) => stream.push(e.delta))
    .listen('.voice.completed', () => stream.finish())
    .listen('.turn.failed', (e) => stream.fail(e.message));

Reach the component either through its Alpine scope (push(), finish(), fail()) or, from anywhere at all, through events — name addresses one stream when several share a page:

// 2. Same three transitions, without touching Alpine.
window.dispatchEvent(new CustomEvent('wirekit-stream-push', {
    detail: { name: 'advisor-1', chunk: token },
}));
window.dispatchEvent(new CustomEvent('wirekit-stream-finish', { detail: { name: 'advisor-1' } }));

Props

Prop Type Default Description
url string|null null SSE endpoint to stream from. Null → the component stays idle.
eventName string 'message' SSE event name to listen for.
doneSignal string '[DONE]' Payload that ends the stream.
announce string 'result' 'result' announces the final text once; 'status' announces only readiness; 'none' renders no role="status" region at all, for a page that has its own. The role="alert" failure region is rendered in every mode.
autoStart bool true Open the stream on init. false → start it from your own control.
initialText string|null null Seed text — resume a completed response (SSR) or show a static example.
simulate string|null null Stream this text token by token from a local timer, with no url — a live-looking demo or a typewriter effect.
simulateSpeed int|null 55 Milliseconds per token in simulate mode.
source string|null 'sse' sse, fetch, or manual — see above.
method string|null 'POST' fetch mode: HTTP method.
body array|string|null null fetch mode: request body. Arrays are JSON-encoded.
headers array|null null fetch mode: extra request headers.
name string|null null Addresses this stream in the wirekit-stream-* events.
startMessage string|null null Announced when streaming begins. Defaults to the translated "Generating response…".
readyMessage string|null null Announced on completion in announce="status" mode.
stoppedMessage string|null null Announced when the reader aborts the stream.
failedMessage string|null null Announced on terminal failure. :message is replaced by the reason.
scope string|null null Scoped personalization key.

The Alpine scope exposes text, status (idle / streaming / done / aborted / failed), the derived isStreaming / isDone / isAborted / isFailed / isTerminal, and the methods start(), stop(), restart(), plus push(chunk) / replace(text) / finish() / fail(message) for manual mode — bind them from the default slot for controls and custom state UIs. The error slot overrides the default failure message.

replace(text) when your rendering is derived from the whole stream rather than accumulated from its parts. Masked text is the clearest case: a placeholder can break across a chunk boundary — ⟦0 in one frame, in the next — so only the full string can be unmasked, and appending would leave the broken placeholder on screen forever. Incremental Markdown is another: a closing fence changes what came before it.

// 3. Re-render the whole thing each token, instead of adding to it.
raw += delta;
stream.replace(unmask(raw));

Setting text directly does the same thing and quietly costs you something: it skips the reduced-motion buffer, which holds tokens back and delivers them in one step for readers who asked for less motion. replace() takes the same path a push takes, so the two are indistinguishable to that reader.

The output slot replaces the component's own output block, so you can keep the state machine and the announcement contract while rendering your own markup — a chat bubble, a diff view, a syntax-highlighted pane. The live regions stay in place either way; only the visible output changes. The default slot is unaffected and stays additive, so a caller can use both at once:

<x-wirekit::stream source="manual">
    <x-slot:output>
        <article class="prose"><span x-text="text"></span></article>
    </x-slot:output>

    <button type="button" x-on:click="stop()" x-show="isStreaming">Stop</button>
</x-wirekit::stream>

Accessibility

  • One live region. A single visually-hidden role="status" aria-live="polite" region announces generating once, then the result (or ready) once. The visible output is deliberately not a live region, so a screen reader is never re-read on every token — the single most important decision this primitive makes.
  • Reduced motion. Under prefers-reduced-motion: reduce, tokens are buffered and the full response is revealed at once — the incremental build is suppressed. The streaming caret only pulses when motion is allowed.
  • Defined terminal states. Abort and connection loss resolve to aborted / failed, and the failure surfaces in a role="alert" region — never a silent hang.

Keyboard Interaction

The output itself has no interaction model — it is streamed text. Any controls you add in the default slot (Stop, Regenerate) are ordinary buttons: Tab to reach them, Enter / Space to activate. There is nothing bespoke to learn.

Design Tokens

Token Used for
--text-wk-md / --color-wk-text / --font-wk-sans Streamed output type
--color-wk-text-muted Streaming caret
--color-wk-danger-text Failure message
--gap-wk-xs Vertical rhythm between output, caret, and controls

Further Reading

Was this page helpful?

Voting requires cookies or local storage. What we store