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: reducethe 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.
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.
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.
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.
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 framing — data: 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 arole="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 |