Inline editing — click a name in a table, it becomes a text field, change it, press Enter — saves navigation to a separate form, but it breaks keyboard users in small ways: Escape does nothing (or closes the whole dialog around the table), blur saves a half-typed value the user wanted to abandon, and after committing, focus falls to <body> instead of returning to the cell.

A well-behaved inline edit has a clear contract: Enter commits, Escape cancels and restores the original value, focus returns to where editing began, and a validation failure keeps the user in edit mode with an explained error. This page, part of keyboard navigation patterns, implements that contract and handles the interactions with surrounding widgets.


Context and prerequisites

An inline edit is a two-state widget:

  • Display mode — the value shown as text, with an “Edit” button (or the text itself as a button) that enters edit mode. A button is focusable and announced as actionable.
  • Edit mode — an input pre-filled with the current value, focused, with Save and Cancel available by keyboard (Enter and Escape) and optionally as visible buttons.

Decisions to make:

  • What does blur do? Commit (spreadsheet-like), cancel, or keep editing. Commit-on-blur is common, but must not commit an invalid value, and must not commit when blur happened because the user pressed Escape.
  • What does Escape do when a parent also listens? Inside a <dialog>, Escape closes the dialog; the inline edit must consume Escape first while editing.
  • Where does focus go after? Back to the edit button for the same item, so the user can continue from where they were.
The inline edit lifecycle In display mode the value is shown with an Edit button. Activating it enters edit mode with an input containing the current value, focused and selected. Pressing Enter or blurring commits: the value is validated; if valid it is saved, display mode returns and focus goes back to the Edit button; if invalid the input stays, with aria-invalid and an error message. Pressing Escape cancels: the original value is restored, display mode returns and focus goes back to the Edit button, and the event does not reach an enclosing dialog. Display Text + "Edit name" button. Button is focusable and named per item. activate Edit Input with current value, focused. Original value remembered. Enter / blur Commit → valid? save : stay Valid: save, back to display. Invalid: stay, show error. Focus returns to the Edit button after save. Escape (any time) Cancel Restore original, back to display. stopPropagation: the dialog stays open.

The core pattern: an inline edit controller

export function inlineEdit(opts: {
  display: HTMLElement;              // contains the text and the edit button
  button: HTMLButtonElement;         // "Edit name"
  getValue: () => string;
  validate: (v: string) => string | null;
  save: (v: string) => Promise<void>;
  label: string;                     // "Name"
  announce: (msg: string) => void;
}) {
  let input: HTMLInputElement | null = null;
  let error: HTMLElement | null = null;
  let original = "";
  let finishing = false;             // guards blur firing during Escape/Enter handling

  const exit = () => {
    input?.parentElement?.remove();
    input = null;
    opts.display.hidden = false;
    opts.button.focus();             // focus returns where editing began
  };

  const commit = async () => {
    if (!input || finishing) return;
    const value = input.value;
    if (value === original) { finishing = true; exit(); finishing = false; return; }
    const message = opts.validate(value);
    if (message) {
      error!.textContent = message;
      error!.hidden = false;
      input.setAttribute("aria-invalid", "true");
      input.focus();                 // stay in edit mode
      return;
    }
    finishing = true;
    try {
      await opts.save(value);
      exit();
      opts.announce(`${opts.label} saved.`);
    } catch {
      error!.textContent = `${opts.label} could not be saved. Try again, or press Escape to cancel.`;
      error!.hidden = false;
      input?.focus();
    } finally {
      finishing = false;
    }
  };

  const cancel = () => {
    finishing = true;
    exit();                          // original value is untouched in the data
    finishing = false;
    opts.announce(`Editing cancelled. ${opts.label} unchanged.`);
  };

  opts.button.addEventListener("click", () => {
    original = opts.getValue();
    const wrapper = document.createElement("div");
    wrapper.className = "inline-edit";
    const id = `ie-${crypto.randomUUID()}`;
    wrapper.innerHTML = `<label for="${id}" class="visually-hidden">${opts.label}</label>
      <input id="${id}" aria-describedby="${id}-err"><p id="${id}-err" class="error" hidden></p>
      <p class="visually-hidden">Press Enter to save, Escape to cancel.</p>`;
    opts.display.after(wrapper);
    opts.display.hidden = true;
    input = wrapper.querySelector("input")!;
    error = wrapper.querySelector(".error");
    input.value = original;
    input.focus();
    input.select();

    input.addEventListener("keydown", (e) => {
      if (e.isComposing) return;     // IME: Enter/Escape belong to the composition
      if (e.key === "Enter") { e.preventDefault(); void commit(); }
      if (e.key === "Escape") {
        e.preventDefault();
        e.stopPropagation();         // do NOT let an enclosing dialog close
        cancel();
      }
    });
    input.addEventListener("blur", () => { if (!finishing) void commit(); });
  });
}

Step-by-step walkthrough

  1. Enter edit mode from a real button. “Edit name” (with the item’s name in its accessible name) is focusable, announced as a button and works with Enter and Space.
  2. Remember the original value on entry. Cancel restores it by simply not saving; nothing in the data changed while editing.
  3. Focus and select the input. Users can type to replace or arrow to adjust; the visually hidden instruction tells screen-reader users that Enter saves and Escape cancels.
  4. Handle Escape first and stop it. stopPropagation() prevents an enclosing dialog’s cancel from firing, as discussed in focus trapping in modal forms.
  5. Commit on Enter and blur, but validate first. An invalid value keeps the user in edit mode with aria-invalid and a message; a failed save keeps their text and explains how to retry or cancel.
  6. Return focus and announce. After saving or cancelling, focus goes back to the edit button, and a short polite message confirms the outcome.

Why blur must not fight Escape

Pressing Escape triggers exit(), which removes the input — and removing a focused element fires blur. A naive blur handler then commits the value the user just tried to abandon. The same happens with Enter: saving removes the input, blur fires, and a second commit starts. The finishing flag makes the order explicit: while a commit or cancel is in progress, blur does nothing. It is a small guard that prevents the most confusing inline-edit bug — Escape that saves.

Keys and events in edit mode Enter validates and commits, and does not propagate. Escape cancels and restores, and is stopped so an enclosing dialog does not close. Tab moves focus away, which commits through blur if valid. Blur caused by removing the input during Enter or Escape handling is ignored. Enter or Escape during IME composition is left to the composition. Key / event Action Propagates? Enter validate, commit no (preventDefault) Escape cancel, restore no (stopPropagation) Tab (blur) commit if valid yes blur during finish ignored — Enter/Escape while composing left to the IME yes

Failure modes and edge cases

1. Escape closes the surrounding dialog

Without stopPropagation, Escape in an inline edit inside a dialog closes both. Stop the event in the input’s handler while editing.

2. Focus lost after save

Re-rendering a table row after save can replace the edit button, and focusing the old reference fails. Focus by a stable id after the update, or restore focus to the row, per restoring focus after an async submission.

3. Commit-on-blur with invalid values

Blurring an invalid value should not silently discard it or save it. Keep edit mode and the error visible; the user can fix it or press Escape.

4. Multiple inline edits at once

Opening a second inline edit while one is active should first commit or cancel the first. Allowing two simultaneous edit modes confuses focus return and blur handling.

5. Optimistic display

Showing the new value immediately while saving is fine, but a failure must revert the display and explain, as in rolling back optimistic updates on failure.

Escape inside a dialog The user is editing the name field inline inside a settings dialog and presses Escape. The inline edit's keydown handler prevents the default, stops propagation and cancels, restoring display mode. Because the event did not reach the dialog, the dialog's cancel event does not fire and the dialog stays open. Focus returns to the Edit name button and the announcer says editing cancelled, name unchanged. A second Escape, now outside the inline edit, closes the dialog as normal. User Inline edit Dialog Escape (editing) restored; focus → Edit name event stopped: dialog stays open second Escape → dialog closes

Verification checklist


Frequently Asked Questions

Should blur commit or cancel?

Commit is the common expectation (spreadsheets, most web apps), provided the value is valid. For destructive or expensive changes, cancelling on blur and requiring Enter or a Save button is safer. Choose one behaviour for the whole product.

Do I need visible Save and Cancel buttons?

They help users who do not know the keyboard shortcuts and touch users who have no Escape key. At minimum, provide them on touch devices; on desktop, the visually hidden instruction plus Enter/Escape is often enough.

How do screen-reader users know the field is editable?

The edit trigger’s accessible name (“Edit name, Ada Lovelace”) says so. Avoid making the text itself editable on click without a button; contenteditable regions are harder to discover and label correctly.


Related

← Keyboard Navigation Patterns