Skip to main content
Copy for LLM

Unsaved Hint

A form that holds what its server does not have yet says so. Put x-wk-unsaved on the element around the fields, a <form> or a card, and <x-wirekit::unsaved-hint> beside the save button. While a field holds a value that is not saved, the field is framed in blue and the hint shows its sentence. Saving brings both back.

Basic Usage

A card with an unsaved field
Unsaved changes

The preview shows the moment after the reader changed the name. In your application the attributes that draw it are set for you: x-wk-unsaved compares each field and marks the ones that differ.

What Counts as Unsaved

x-wk-unsaved compares each field inside it with the value the field was last saved with, at first the one the server rendered. It tracks every field whose wire:model has no modifier that sends the value by itself. Unlike wire:dirty, which clears as soon as a request has sent the value, a field stays unsaved until a request saves it.

The field Tracked
wire:model="name" Yes
wire:model.number="quantity", or any modifier that changes how the value is read Yes
wire:model.live, .blur, .change or .lazy No: the value reaches the server on its own
A file field No
Anything inside an element with data-wk-unsaved-off No

A component whose wire:model sits on its own root, such as a combobox or a multi-select, is tracked the same way. Its value lives in the component's state rather than in the field, and that state is what is compared.

A request of the component that comes back without a validation error counts every field it sent as saved. A request that comes back with a validation error saves nothing: after a save the server refused, the fields stay framed, the hint stays, and .confirm still asks. A value the server writes into a field itself, when it loads, resets or rewrites one, counts as saved at once.

Any request sends every deferred change with it, not only the save: a button that adds a row, a field with wire:model.live and a poll send them too, and the fields count as saved from then on, although nothing stored them. To count only the save itself, use .until-saved.

Counting Only a Save

x-wk-unsaved.until-saved counts a field as saved only after a save, which is one of two things:

  • a wire:submit of the form around the field that comes back without a validation error;
  • wirekit:saved, dispatched by the component in the request that saved.

Every other request leaves the fields unsaved, the button that adds a contact included:

<form wire:submit="save" x-wk-unsaved.until-saved.confirm>
    <x-wirekit::input label="Supplier name" wire:model="name" />
    <x-wirekit::button wire:click="addContact">Add a contact</x-wirekit::button>

    <x-wirekit::row justify="end" gap="sm" align="center">
        <x-wirekit::unsaved-hint />
        <x-wirekit::button type="submit">Save</x-wirekit::button>
    </x-wirekit::row>
</form>

A save that is not a wire:submit, such as a button with wire:click, says so with wirekit:saved. Give the scope a name, and dispatch the event with that name once the save has gone through:

<div x-wk-unsaved.until-saved="draft">
    <x-wirekit::input label="Title" wire:model="title" />

    <x-wirekit::row justify="end" gap="sm" align="center">
        <x-wirekit::unsaved-hint />
        <x-wirekit::button wire:click="storeDraft">Save draft</x-wirekit::button>
    </x-wirekit::row>
</div>
public function storeDraft(): void
{
    // 1. A validation error ends the action here, and the field stays unsaved
    $this->validate(['title' => 'required|max:120']);

    // 2. Store the title on the post this component edits
    $this->post->update(['title' => $this->title]);

    // 3. Count the fields of the scope named "draft" as saved
    $this->dispatch('wirekit:saved', scope: 'draft');
}

What the event counts as saved depends on what it names:

wirekit:saved with Counts as saved
scope: 'draft' The fields of the scope named draft
paths: ['title', 'body'] The fields bound to those properties
Both The fields of that scope bound to those properties
Neither Every field

A dispatch of a component counts that component's fields. The page can send the event as well: from Alpine, $dispatch('wirekit:saved', { scope: 'draft' }) counts inside the component around the element, and from a script, an event sent to window counts on the whole page:

window.dispatchEvent(new CustomEvent('wirekit:saved', { detail: { paths: ['title'] } }));

wirekit:saved counts in a scope without .until-saved too, and also in a response with a validation error, for a save that stored part of a form.

The Field

An unsaved field carries data-wk-unsaved, and the stylesheet gives it the border --color-wk-border-unsaved, a blue, so it reads as neither an error nor a success. A field with a prefix, a clear button or pills draws the color on the frame around them. The edge is drawn once more as an outline inside the border, so it shows under a theme whose border width is 0px, such as the Material and Minimal presets, and the field does not move when it changes. An invalid field keeps its error border:

An unsaved field and an invalid one

Enter a complete email address.

The scope carries data-wk-has-unsaved while any field in it is unsaved, for a style of your own, such as a save button that stands out:

[data-wk-has-unsaved] .save-button {
    box-shadow: 0 0 0 2px var(--color-wk-border-unsaved);
}

Asking Before the Reader Leaves

x-wk-unsaved.confirm asks before the page is left with an unsaved field. A reload, a closed tab, a typed address and an ordinary link get the browser's own question, in the browser's words. A wire:navigate link never unloads the page, so the browser does not ask; there the scope asks the hint's question, in the reader's language. A step back or forward through the history is not held.

<form wire:submit="save" x-wk-unsaved.confirm>
    <x-wirekit::input label="Supplier name" wire:model="name" />

    <x-wirekit::row justify="end" gap="sm" align="center">
        <x-wirekit::unsaved-hint />
        <x-wirekit::button type="submit">Save</x-wirekit::button>
    </x-wirekit::row>
</form>

A save that redirects does not ask: the response is weighed before the redirect runs, so its fields count as saved by then. A scope without a hint asks in English unless it names its own question:

<div x-wk-unsaved.confirm data-wk-unsaved-confirm="{{ __('Discard the changes to this supplier?') }}">

Your Own Sentence

text replaces the sentence, and the slot replaces it with markup:

A sentence of your own
Changes to this supplier are not saved yet

The Directive

x-wk-unsaved takes two modifiers, which combine (x-wk-unsaved.until-saved.confirm), and an optional name:

Directive On Does
x-wk-unsaved the element around the fields Marks each unsaved field with data-wk-unsaved, itself with data-wk-has-unsaved, and shows the hints inside it
x-wk-unsaved.confirm the same element Also asks before the page is left with an unsaved field
x-wk-unsaved.until-saved the same element Counts a field as saved only after a wire:submit of its form or a wirekit:saved
x-wk-unsaved="draft" the same element Names the scope, for wirekit:saved with scope: 'draft'. The name is read as written, not evaluated

Props

Prop Type Default Description
text string|null null The sentence shown while a field in the scope is unsaved. Without it the hint reads "Unsaved changes", in the reader's language
question string|null null What x-wk-unsaved.confirm asks before a wire:navigate visit. Without it: "You have unsaved changes. Leave this page anyway?", in the reader's language
scope string|null null Scoped personalization name

Accessibility

  • The hint is a polite live region (role="status") that is always in the page. When the first field becomes unsaved, its sentence is written again, so a screen reader announces it once; it says nothing when a save clears it.
  • While nothing is unsaved, the hint takes no room and its sentence is out of the accessibility tree, so a screen reader does not find a stale "Unsaved changes" in the page.
  • The frame of an unsaved field is a cue beside the sentence, not the only one: the hint says in words what the color shows (WCAG 1.4.1). The default border is above 3:1 against the field in the light and the dark theme (WCAG 1.4.11).
  • The dot before the sentence is decorative (aria-hidden).

Keyboard Interaction

The hint takes no focus and has nothing to operate: it is a sentence. The fields around it keep their own keys, and nothing about the keyboard changes when a field turns unsaved. With .confirm, the question before a wire:navigate visit is the browser's own dialog, answered with Enter or Escape.

Pitfalls

  • The hint belongs inside the scope. A hint outside any x-wk-unsaved stays hidden.
  • Without .until-saved, any request that comes back without a validation error counts as saving. See What Counts as Unsaved: a field with wire:model.live in the same component makes the others count as saved with every request it sends. .until-saved waits for the save.
  • A save that fails without a validation error looks like one that went through. A conflict or a refused action that only shows a toast leaves no error in the response, so a scope without .until-saved, or a wire:submit under it, counts the fields as saved. Put the reason into the error bag, $this->addError('title', __('Someone else saved this post first.')), and they stay unsaved.
  • Under .until-saved, a save without wire:submit needs wirekit:saved. Without the event the fields stay unsaved after the save, and .confirm asks before the redirect that follows it.
  • A browser asks a reload in its own words. The text of that question cannot be set by a page; question reaches the wire:navigate case only.

Design Tokens

Token Used for
--color-wk-border-unsaved The border and the inner outline of an unsaved field or of its frame, and the dot before the sentence
--color-wk-text-muted The sentence
--text-wk-sm The sentence's size
--font-wk-sans The sentence's font

See Also

  • Form for the error-announcement policy of the controls inside
  • Card for the footer the hint usually sits in

Updated in WireKit v2.65.0 (2026-10-05)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store