---
title: Inline Edit
description: Edit a value in place, with an explicit confirm step
visibility: guest
draft: false
---

# Inline Edit

Turns a displayed value into an editor in place and back again. The reader stays where they are — no dialog, no separate form page — and nothing is written until they confirm.

> **Not for a yes/no value.** Reach for [toggle](/components/toggle) there: the control is already its own read state, so the confirm/cancel machinery buys nothing and costs two extra tab stops per row. This component earns its weight when the value is text a reader has to type — and only then.

## Basic Usage

:::preview{title="Edit a title in place"}
<x-wirekit::inline-edit name="title" label="Project title" value="Quarterly report" context="Q3 planning" />
:::

The component does not save anything. It emits an event when the reader confirms, and your Livewire component decides what that means:

:::source{language="blade"}
<x-wirekit::inline-edit
    name="title"
    label="Project title"
    :value="$post->title"
    :context="$post->reference"
    wire:wirekit:inline-edit-confirmed="save($event.detail.value)" />
:::

:::source{language="php"}
public function save(string $value): void
{
    $this->post->update(['title' => $value]);

    // Tell the component it worked. It does NOT infer this from the round trip
    // ending — a validation failure ends one too.
    $this->dispatch('wirekit:inline-edit-saved', name: 'title');
}
:::

Livewire compiles any non-reserved `wire:<event>` into an Alpine listener, so no wrapping element is needed.

## The editor stays open until the save is confirmed

This is the part worth reading twice. When the reader confirms, the component enters a saving state and **waits**. It closes when you say the save succeeded, and not before.

That is deliberate: the end of a round trip is not evidence of success. A validation failure completes a request too, and a component that closed there would discard the error message you just rendered, leaving the reader looking at their old value with no explanation.

Three ways the outcome is reported:

| Event | Meaning |
| --- | --- |
| `wirekit:inline-edit-saved` | Written. The editor closes and focus returns to the trigger |
| `wirekit:inline-edit-failed` | Rejected. The editor stays open, focus does **not** move, so the reader is still looking at what they must fix |
| neither, within `saveTimeout` | No answer arrived. The editor gives up rather than staying busy forever, and says the state is unknown |

Filter by `name` in the event detail when a page holds several editors.

## Livewire

The component owns the interaction; your component owns the write. It emits `wirekit:inline-edit-confirmed` with `name`, `value` and `previous`, and waits.

::: warning
**`wire:model.blur` does not save in Livewire 4.** `.live` became its own modifier, so what used to mean "send on blur" is now spelled `wire:model.live.blur`. Every older guide — and muscle memory — says otherwise, and the failure is silent: the field looks bound and nothing reaches the server. Do not reach for it here in any case; this component commits explicitly, which is the point of it.
:::

**Do not bind the draft two-way.** A `wire:model` on the control writes on every keystroke, which leaves nothing to confirm and nothing for cancel to restore. The draft stays local until the reader commits — that is the whole design, not an optimization.

### Validation errors

A validation failure comes back as re-rendered HTML with a filled error bag, not as a transport error. The component reads the bag under the field's `name`, keeps the editor open, links the message with `aria-describedby`, and announces it:

:::source{language="php"}
public function save(string $value): void
{
    $this->validate(['title' => 'required|max:120'], [], ['title' => $value]);
    // On failure Livewire re-renders and the bag is filled — nothing else to do.

    $this->post->update(['title' => $value]);
    $this->dispatch('wirekit:inline-edit-saved', name: 'title');
}
:::

### Surviving a morph

A Livewire morph must not close an open editor or lose a draft. Three rules make that hold, and all three are in the component already:

- The mode switch uses `x-show`, not `x-if`. Livewire stops patching attributes while a visibility state differs, so `x-show` survives; `x-if` would tear the open editor down mid-edit.
- Nothing server-rendered sits on the element that toggles. An id or an `aria-busy` there would stop being updated by the morph.
- There is no `wire:ignore` on the root — it would cut the error text and the displayed value off from updates too.

**Add `wire:key` when you render these in a loop**, so a morph keeps each editor attached to its own row.

### Several editors at once

`exclusive` (default `true`) closes any other open editor when one opens. Without it a reader can carry ten unsaved drafts, and a `wire:navigate` takes all ten with it. If you turn it off, the browser still warns before a navigation that would discard an open draft.

## Affordance

Two independent choices: what the reader **sees**, and what **opens** the editor.

:::preview{title="The three trigger styles"}
<x-wirekit::stack gap="md">
    <x-wirekit::inline-edit name="always" label="always — the pencil is always there" value="Visible affordance" />
    <x-wirekit::inline-edit name="hover" label="hover — appears on pointer, forced visible on focus and touch" value="Quieter in a dense table" trigger="hover" />
    <x-wirekit::inline-edit name="focus-only" label="focus-only — nothing until it is tabbed to" value="Quietest of the three" trigger="focus-only" />
</x-wirekit::stack>
:::

::: warning
**`always` costs a tab stop per field.** Twenty editable rows are twenty extra stops before anyone reaches the content below them. In a dense table `hover` or `focus-only` is usually the kinder choice — but pick it for that reason, not because the pencil looks untidy.
:::

There is deliberately **no** way to remove the button. Plain text is not focusable, so a value with no button cannot be reached by keyboard at all. `focus-only` gives the quiet appearance without that defect: nothing is shown until the reader tabs to it, and then it is a real, visible, full-size button.

`openOnValueClick` (default `true`) makes the value itself clickable. It is a separate switch because it answers a different question, and the value never becomes the button — a button named *Jane Doe* tells a screen-reader user nothing about what it does, and giving a control a name that is not its visible text breaks voice control.

Two behaviors follow from that clickable value, and both only show up on a real page:

- **Dragging across the value selects text, it does not open the editor.** Selecting requires moving the mouse, so a movement beyond a few pixels is read as a selection.
- **A link inside the value follows the link.** Otherwise the link would be unreachable by mouse.

## Keyboard

| Key | Action |
| --- | --- |
| `Enter` | Confirm |
| `Cmd/Ctrl + Enter` | Confirm — works in every control type |
| `Escape` | Cancel and restore the previous value |
| `Tab` | Reach the trigger in every affordance style |

In a **textarea** `Enter` writes a line, so it does not commit — `Cmd/Ctrl + Enter` does. That is also why the confirm and cancel buttons cannot be switched off for that control: without them and without knowing the shortcut, a reader would have no way to save at all, and the remaining exit discards.

Both `Enter` and `Escape` stop where they are handled. Without that, `Escape` inside a modal would close the modal as well as canceling the edit, and `Enter` inside a `<form wire:submit>` would submit the whole form instead of committing one field.

## Where focus goes

Focus is decided by **how the reader left**, not by whether the save worked:

| Leaving by | Focus |
| --- | --- |
| Confirming with the keyboard | Back to the trigger |
| Canceling / `Escape` | Back to the trigger |
| Confirming with the mouse | Back to the trigger |
| Clicking away without confirming | **Stays where the reader clicked** — pulling them back would undo the move they just made |
| A save that failed | **Does not move** — the error is here, and moving away hides it |

## What you can edit

Four built-in types, chosen with `control`. Each one edits in place the same way — only the control inside the open editor differs.

| `control` | Editor | Reach for it when | Note |
| --- | --- | --- | --- |
| `text` *(default)* | Single-line input | Titles, names, labels, short free text | — |
| `textarea` | Multi-line input | Descriptions, notes, anything wrapping | The editor grows downward, and the buttons stay at the top edge next to where you are typing |
| `number` | Numeric input | Quantities, prices, counts | Pass `min`, `max` and `step` straight through |
| `select` | Dropdown | A value from a fixed set | Needs `options` as `key => label`, and `displayValue` for what read mode shows |

Anything else goes through the [`editor` slot](#bringing-your-own-control) — a date picker, a tags input, a combobox — so an uncovered type never has to wait for a release.

:::preview{title="The four built-in types"}
<x-wirekit::stack gap="lg">
    <x-wirekit::inline-edit
        name="title"
        label="Title (text)"
        value="Quarterly report"
    />

    <x-wirekit::inline-edit
        name="summary"
        label="Summary (textarea)"
        control="textarea"
        value="A short description that wraps onto more than one line."
    />

    <x-wirekit::inline-edit
        name="seats"
        label="Seats (number)"
        control="number"
        value="12"
        min="0"
        step="1"
    />

    <x-wirekit::inline-edit
        name="status"
        label="Status (select)"
        control="select"
        value="review"
        display-value="In review"
        :options="['draft' => 'Draft', 'review' => 'In review', 'done' => 'Done']"
    />
</x-wirekit::stack>
:::

::: tip
**`select` stores a key and shows a label**, so read mode needs `displayValue` — otherwise it prints `review` where you meant *In review*. The same applies whenever the stored value is not the readable one: an amount stored as `1299`, a date stored as an ISO string.
:::

## Bringing your own control

The `editor` slot covers everything the four types above do not, so an uncovered type never has to wait for a release:

:::source{language="blade"}
<x-wirekit::inline-edit name="tags" label="Tags" :value="$post->tags">
    <x-slot:editor>
        <x-wirekit::tags-input x-ref="control" x-model="draft" />
    </x-slot:editor>
</x-wirekit::inline-edit>
:::

The slot compiles inside the component's own Alpine scope, so `draft`, `commit()` and `cancel()` resolve with nothing threaded in. In exchange there is a contract, and it is checked rather than assumed — in development a missing ref is reported by name:

1. **Bind `x-model="draft"`.** That is the value the component commits.
2. **Set `x-ref="control"`.** Focus placement and the accessibility wiring both aim at it; without it they simply do nothing.
3. **Handle `Escape` yourself if your control is composite** (a combobox, a multi-select). Two handlers for one key make one keypress do two things — the first `Escape` should close your list, the second should cancel the edit.
4. **Label it once.** Either your control carries the label or this component does. Both means a duplicate announcement.

::: warning
**Do not drop this into a data-grid cell.** Inside a `role="grid"` the arrow keys belong to the grid's own navigation, so a control that claims them fights the table and one keypress does two things. That conflict must be resolved before the component belongs in a grid cell.
:::

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | `string` | `null` | Field name, carried in every event detail so several editors can be told apart |
| `label` | `string\|null` | `null` | Rendered in both modes, so the field is never unlabelled while reading |
| `value` | `string` | `''` | The current value |
| `context` | `string\|null` | `null` | Names the record in the trigger's accessible name |
| `trigger` | `string` | `'always'` | `always` · `hover` · `focus-only` |
| `openOnValueClick` | `bool` | `true` | Clicking the value opens the editor |
| `exclusive` | `bool` | `true` | Opening this editor closes any other open one |
| `hint` | `string\|null` | `null` | Help text, linked via `aria-describedby` |
| `error` | `string\|null` | `null` | Error text, linked via `aria-describedby` |
| `control` | `string` | `'text'` | `text` · `textarea` · `number` · `select` |
| `options` | `array` | `[]` | Key => label pairs for `control="select"` |
| `displayValue` | `string\|null` | `null` | What read mode shows when the stored value is not the readable one. Resolves itself for a select |
| `emptyText` | `string\|null` | `null` | Read-mode text when there is no value |
| `commitOn` | `string` | `'explicit'` | `explicit` · `blur`. Forced to `explicit` for a select |
| `actions` | `bool` | `true` | Show confirm/cancel. Cannot be disabled for a textarea |
| `size` | `string` | `'md'` | `sm` · `md` · `lg` |
| `loading` | `bool` | `false` | Hold the saving state from the server |
| `loadingTarget` | `string\|null` | `null` | Scope the loading state to one action (`loadingTarget`, not `target`) |
| `width` | `string` | `'full'` | `full` takes the container width so read and edit boxes match; `auto` measures the content |
| `scope` | `string\|null` | `null` | Scoped personalization key |

::: tip
`context` matters more than it looks. In a list of twenty rows, twenty triggers named *Edit title* are twenty identical entries in a screen reader's control list. `context="Q3 planning"` makes each one say which record it belongs to.
:::

## Accessibility

- The label renders in **both** modes and sits outside the part that toggles, so the field is never unlabelled while reading and the label does not jump into place on open
- The trigger is a real `<button type="button">` with a 44×44 touch target
- The value is a `role="presentation"` container, never a button — so its accessible name is not the value text
- `aria-describedby` composes the hint and error ids, and is never emitted empty
- Confirm and cancel use `aria-disabled` while saving, never `disabled` — a disabled button leaves the tab order mid-interaction, which loses the reader's place exactly while they wait to find out whether it worked
- A second `Enter` during a save does not submit again
- The save outcome is announced through a polite live region

## Keyboard Interaction

| Key | Action |
| --- | --- |
| `Tab` | Move to the trigger, then between confirm and cancel while editing |
| `Enter` | Confirm |
| `Cmd/Ctrl + Enter` | Confirm |
| `Escape` | Cancel, restoring the previous value |

## Pitfalls

- **Do not bind `wire:model` to the draft.** A two-way binding writes on every keystroke, which leaves nothing to confirm and nothing for cancel to restore. The draft is local until the reader commits.
- **Do not infer success from the request finishing.** Dispatch the saved event; a validation failure completes a request too.
- **Add `wire:key` when rendering these in a loop**, so a morph keeps each editor attached to its own row.

## Design Tokens

| Token | Purpose |
| --- | --- |
| `--color-wk-bg-input` | Editor background |
| `--color-wk-border-strong` | Editor border |
| `--color-wk-text` | Value and label text |
| `--color-wk-text-muted` | Trigger, hint, and the empty-value placeholder |
| `--color-wk-danger-text` | Error text |
| `--color-wk-success-text` | Confirm control |
| `--radius-wk-md` | Editor corner radius |
| `--ring-wk-width` | Focus ring |

## Usage & Conventions

::: info
The component owns the editing interaction and nothing else. Saving, validation, authorization and optimistic updates stay in your Livewire component, which is why the event carries `name`, `value` and `previous` and then gets out of the way.
:::
