Skip to main content
WireKit
Copy for LLM

Optimistic UI

The announcement contract — what an optimistic update owes a screen-reader user, and how to build one that keeps the promise.

An optimistic update shows the result of an action before the server has agreed to it. For a sighted user that is a latency win and nothing else. For a screen-reader user it is a promise that can be withdrawn — and a withdrawn promise is easy to make incomprehensible.

There is no WAI-ARIA APG pattern for this. That is why the rules below are written down rather than left to judgment: when you build an optimistic interaction on top of WireKit, this page is the specification you are building against.

Scope: mutations only. Changing something and showing it right away. Sorting, filtering, pagination and form submission are query round trips — nobody can show a result nobody knows yet — and are deliberately not covered. If you need those, the honest pattern is to acknowledge the intent (flip the sort arrow, mark the filter active) and mark the content as loading; that is a different state machine.

Which components already have it

These components ship the optimistic path. Switch it on with optimistic and the contract on this page is already kept for you — you do not build any of it. Each one has a live demo on its own page: accept it, refuse it, and watch a slow answer.

Form controls

checkbox · color-picker · combobox · date-picker · editor · input · multi-select · number-input · otp-input · password-input · range-slider · rating · select · slider · tags-input · textarea · time-picker · toggle

Display and overlay

calendar · dropdown.checkbox-item · reaction · segmented-control · toggle-button

Three components deliberately do not offer it: inline-edit, dropdown.radio-item and radio. Each already carries a save seam or a grouped selection model of its own, and the generic layer would duplicate that rather than serve it.

The state machine

Four states and four exits, and the fourth is the one implementations get wrong:

idle ──apply()──▶ optimistic ──onSuccess──▶ confirmed ──▶ idle
                       │
                       ├──onFailure/onError──▶ rolled-back ──▶ idle   (failure: 'undo', the default)
                       ├──onFailure/onError──▶ rejected ────▶ idle   (failure: 'keep' — the value stays)
                       └──onCancel──────────▶ idle          (NOT rolled-back)

Which of the two failure exits a component takes is failure, and it is decided per component rather than per request — see rule 7.

A cancel is not a failure. An aborted request refused nothing, so it restores the value silently and returns to idle. Announcing a rollback for it tells the user their action was rejected when it was never judged. This matters because the older Livewire channel fired for an abort and a rejection alike — the wrong behavior is the one you get by default.

1. One announcement on the success path, a second only on deviation

The flip announces once, hedged so the provisional state is audible as provisional. Confirmation is silent: what was announced is what happened.

Only deviation speaks a second time, and that is what gives a rollback its meaning — hearing a second announcement is itself the signal. Announce twice on the happy path and the rollback becomes indistinguishable from a confirmation.

The hedge belongs in the verb, not in a concatenation. "Liked, saving" works because saving is progressive: it says the thing is still in flight. "Pending — Liked" does not — it names two states where the reader needs one status, and a screen reader reads it as a list.

Every announcement is a translation key. A literal is read aloud in English to everybody.

2. Yield to the field's own error region

Where the component already renders an active error live region, the optimistic layer stays silent on failure. The field's message is the actionable one: "Email is required" tells the user what to do, "could not save" does not, and WCAG 3.3.1 wants the specific error identified.

Two details decide whether this works:

  • Active means carrying a message, not merely present in the DOM. An empty error region is not a speaker. A presence check would silence the optimistic layer even when nothing else is talking — which is the common case, since form controls render that region by default.
  • The arbitration is about the failure, not the promise. At the flip there is no error message to pre-empt, and the hedge is the one thing that makes the state audible as provisional. Silencing it there is the mistake that reads as caution.

3. Display and voice are separate channels

A toast is a reasonable failure surface, and it is not an exemption from rule 2: a toast announces through a live region too, so a failure toast on a form field pre-empts exactly the message rule 2 protects.

  • Display: the toast appears, always.
  • Voice: it stays silent wherever an active error region exists.

4. Focus never moves on a rollback

A rollback arrives on the server's schedule, not the user's. Moving focus then costs them their place for a reason they could not predict, and gains nothing an announcement does not already provide.

This holds even when the rolled-back control is no longer the right place to be. If that becomes a real problem, the answer is an affordance the user can act on, not a jump they cannot foresee.

5. Reduced motion is about motion, not about waiting

prefers-reduced-motion says "do not move things unnecessarily". It does not say "make me wait for the server". So the optimistic layer never asks about it: the state flips as always, and only the transition is skipped.

Skipping the optimism under reduced motion makes the application slower for exactly the users who asked for less movement.

6. The pending state has to be visible and legible

The provisional state is a state, so it carries aria-busy while in flight. Its visual treatment clears both contrast floors — 1.4.3 for the text, 1.4.11 for any non-text indicator. A pending style that only dims fails 1.4.3 at the moment the user most wants to read it.

What it looks like

The provisional state is drawn as a dashed outline around the control, breathing slowly. Dashed on purpose: a solid outline in the accent color is what focus looks like in this library, and a pending control that borrows the focus appearance tells the reader the wrong thing.

A control at rest, and the same control mid-flight

The preview above sets the marker and aria-busy literally, because a documentation preview has no Livewire component behind it and therefore no round trip to be mid-way through. The block underneath is what you actually write. Anywhere a real request is in flight, the layer sets both attributes itself and removes them when the answer arrives.

Both shapes are in the preview on purpose. A toggle and a checkbox hide their native input and draw the visible switch in a sibling element, so the outline lands on that sibling rather than on the element carrying aria-busy. Everything with a visible field — input, select, textarea — is outlined directly. You do not choose between them; the stylesheet does.

Never signal pending by dimming. It is the tempting implementation and it degrades 1.4.3 at the moment the reader most wants to read the value. Only a non-text indicator is added here, which is 1.4.11's 3:1 floor, and the stock accent clears it by a wide margin on every surface in the palette.

Under prefers-reduced-motion: reduce the outline stays and only the breathing stops. The outline is the information; the movement is polish, and gating information behind a motion preference would hide the state from the readers most likely to want it steady.

The indicator uses --color-wk-accent, so a theme that sets that token to something faint requires a second look: the pending state goes faint with it, and a provisional value that is hard to see is worse than one that is obviously provisional. Measure it against your own surfaces before shipping a low-contrast accent.

7. An undo may not destroy what you wrote

For a toggle, a rating or a select, an undo restores the previous choice and costs nothing. For a free-text field the previous value is the server's and the new one is your work — so an undo would delete what you just typed because a save failed. No editor behaves that way.

So a text field takes a different exit: failure: 'keep'. The value stays, the state becomes rejected rather than rolled-back, and the announcement carries the reassurance as well as the failure — because the most urgent question a refusal raises is is my text gone?, and "not saved" alone leaves it unanswered.

<div x-data="wirekitOptimistic({
    value: 'draft',
    action: 'saveDraft',
    failure: 'keep',
    messages: { pending: 'Saving', kept: 'Could not save. Your entry is still here.' },
})">

The field is not marked invalid, and that is deliberate. aria-invalid claims the content is wrong; a save that failed on the network says nothing about the content. Borrowing the attribute would tell a screen-reader user they made a mistake when they did not.

'keep' does not by itself make a mixed control safe. Where a component has both a typed field and discrete controls, putting the discrete half on 'undo' is not enough: a value typed while the discrete half's request is in flight would be overwritten by that request's rollback. Such a component has to answer which half owns the value before either exit fits.

What a test has to prove

Assert the rendered accessibility tree, not the markup:

  1. the accessible name and state change on the flip,
  2. after a rollback they match the server's value again,
  3. exactly one announcement leaves on the success path and none on a cancel,
  4. focus is unchanged across all four exits.

Count announcements; do not read the current one. "Announced once" and "announced nothing further" are claims about the number of writes, and the final text cannot tell them apart — a test that reads the region at the end passes against an implementation that announced three times. A MutationObserver over the live region is the cheapest honest recorder.

Two more things worth knowing before you write that test:

  • A Livewire validation rejection is a successful response carrying an error bag, so it confirms rather than fails. A rollback needs a genuine failure.
  • A failed request never re-renders, so waiting for server-rendered text can never see it. Wait on client state instead.

An expected refusal is still an exception — keep it out of your error tracker

A rollback needs a genuine failure, and a genuine failure is a thrown exception. Laravel reports those. So every refusal your application makes on purpose — a rule said no, a quota is full, the item was already taken — arrives in your error tracker as a production incident, indistinguishable from a real one.

This is easy to miss because it is invisible in development and grows with traffic rather than with defects. On our own documentation site a single reader browsing one component page produced 46 reports from the demo that refuses by design.

Mark the exception as one that is not worth reporting, by implementing Illuminate\Contracts\Debug\ShouldntReport. It still fails the request, so the rollback happens exactly as before:

// 1. The contract says "this is a real failure, but not an incident".
//    Laravel's handler checks for it before reporting anything.
use Illuminate\Contracts\Debug\ShouldntReport;

final class QuotaReached extends \RuntimeException implements ShouldntReport {}
// 2. Throw it from the Livewire method the control calls. The optimistic layer
//    sees a failed request and rolls back; the error tracker sees nothing.
public function toggleLike(bool $liked): void
{
    if ($this->quotaExhausted()) {
        throw new QuotaReached('You have used every like for today.');
    }

    // ...
}

If you are on an older Laravel, the equivalent is dontReport(QuotaReached::class) in the exception handler — the contract is the same idea with the list kept somewhere else.

Do not reach for a validation error instead. It reads like the tidier option and it silently removes the rollback: a validation rejection is a successful response carrying an error bag, so the layer confirms the value rather than restoring it, and the reader keeps a change the server refused.

Using it

Load wirekit-optimistic.js alongside whichever bundle you already use. It is a separate file on purpose: loading it is how you accept the contract above, so applications that do not use it pay nothing for it.

Every component listed above documents it on its own page, with a demo you can accept, refuse, and watch answer slowly — Toggle is a good place to start. To build your own, mount the factory and give it the Livewire method to call:

<div x-data="wirekitOptimistic({ value: false, action: 'toggleLike', mode: 'reject' })">
    <button type="button" x-bind:aria-pressed="value" x-bind:aria-busy="isPending" x-on:click="toggle()">
        <span x-text="value ? 'Liked' : 'Like'">Like</span>
    </button>

    {{-- Rendered unconditionally and starting empty. See below. --}}
    <div class="sr-only" aria-live="assertive" aria-atomic="true" x-text="announcement"></div>
</div>

Three things in that snippet are contract rather than style:

  1. The live region is rendered unconditionally and starts empty. A region that arrives together with its text is a new node, not a changed region, and assistive technology says nothing at all. This is the single most common way an optimistic implementation ends up silent while looking correct.
  2. The directives call methods. Under Alpine's CSP build a directive holds one expression and may not name a global, so apply(true); $wire.save() is refused — and it does not fail loudly: the element ends up with an empty scope and every directive on it silently does nothing.
  3. mode: 'reject' for a toggle. With a queue, a twice-flipped toggle resolves by the order the responses arrive in — network timing, which is both wrong and impossible to test.

x-ref="control" on a native input is worth adding: a checkbox flips itself before any handler runs, and a binding only writes when the bound value changes, so a refused flip would leave the control showing a state nothing agreed to.

If the Livewire method named in action does not exist — a typo, or Livewire not loaded yet — the control refuses the flip rather than showing a change nothing was asked to make, and says so in the console when the factory is given debug: true. WireKit's own components pass config('app.debug') there, so the warning appears where you develop and nowhere else; hand-mounting the factory, you choose. A control that does nothing and stays quiet is the one failure this layer is built to avoid.

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store