Accessibility

The Tab key problem in contenteditable

Tab has to indent lists and move between table cells. It also has to let a keyboard user leave. Those requirements are in direct conflict, and picking either one alone produces a WCAG Level A failure or an editor writers hate.

8 min read

Every rich text editor built on contenteditable eventually has to answer one question, and both obvious answers are wrong.

Tab must do editor things. Indent a list item. Move to the next table cell. Insert an indent in a code block. Writers expect this because every word processor they have used works that way.

Tab must also let people leave. It is the only way a keyboard user moves between controls on a page. If the editor swallows it, focus goes in and never comes out.

You cannot have both, and choosing either one alone produces either a WCAG Level A failure or an editor writers find broken.

What the failure actually looks like

This is not a theoretical severity rating. A keyboard-only user tabs through your page, reaches the editor, and presses Tab to continue to the Save button. Instead they get an indent. They press it again — another indent. Shift+Tab outdents. Escape does nothing.

There is no way forward and no way back. The remaining options are the mouse they cannot use, or reloading the page and losing what they wrote. WCAG calls this 2.1.2 No Keyboard Trap, Level A, and it is one of the few criteria where the failure is total rather than degrading: the feature is not harder to use, it is impossible to exit.

We found exactly this in our own editor. Tab was preventDefaulted and inserted spaces, with no escape at all. It had been shipping for years — and our published accessibility statement said “No keyboard trap: users can always Esc out or Tab past the editor.” The statement had been written from intent rather than from behaviour, and nothing connected the two.

Wrong fix 1: just let Tab through

The tempting one-line fix:

// Don't do this
editable.addEventListener("keydown", e => {
  if (e.key === "Tab") return;   // let the browser move focus
});

Now the editor passes 2.1.2 and fails at being an editor. Tab no longer indents list items. Tab no longer moves between table cells — which for many users is the only way they navigate a table, so you have traded one accessibility problem for another. Nested lists become mouse-only.

You will get this back as bug reports within a week, someone will restore the old behaviour, and the trap returns.

Wrong fix 2: Tab escapes, Ctrl+Tab indents

Better, and still wrong. Two reasons.

Ctrl+Tabswitches browser tabs and is not reliably interceptable. More fundamentally, this inverts the common case: indenting a list is something a writer does constantly, leaving the editor is something they do occasionally. Putting the frequent action behind a modifier and the rare one on the bare key is backwards, and it breaks every writer’s muscle memory to satisfy a checkbox.

The pattern that works: a separate, announced escape

Keep Tab doing its editing job. Provide a different key that leaves, and — the part most implementations forget — make it discoverable.

editable.addEventListener("keydown", e => {
  if (e.key !== "Escape") return;

  // A dropdown or dialog must get its own Escape first, or closing a
  // colour picker throws the user out of the editor entirely.
  if (anythingOpen()) return;

  e.preventDefault();
  e.stopPropagation();
  focusToolbar() || focusNextElementAfterEditor();
});

Escape is the right key. It already means “get me out of here” everywhere else, screen reader users try it first, and it is not otherwise bound inside the editing surface once dialogs have had their turn.

Where should focus land? The toolbar, not the next element on the page. It keeps the user inside the component they were working in, and from the toolbar a single Tab continues through the page normally. Jumping straight past the editor loses the toolbar entirely for anyone who wanted it.

The half everyone skips

An escape hatch nobody knows about is not an escape hatch. If the only documentation is a help page, the user stuck in your editor cannot read it — they are stuck in your editor.

The accessible name of the editing region is the one string a screen reader announces the moment focus arrives. That is where the instruction belongs:

const label = editable.getAttribute("aria-label") || "";
editable.setAttribute("aria-label", label + " Press Escape to leave the editing area.");

On entry the user now hears: “Rich text editor. Editing area. Press Escape to leave the editing area.” They are told the way out before they need it, by the same announcement that tells them where they are. No documentation, no discovery problem.

Sighted keyboard users get nothing from an aria-label, so if your editor has a visible help or shortcut sheet, the escape belongs there too. It should not be the only place.

What about Shift+Tab at the start?

Worth thinking about explicitly. A user tabbing backwards into the editor hits the same wall from the other side. Escape solves both directions at once — it exits regardless of how focus arrived — which is another reason to prefer one clear exit over trying to make Tab context-sensitive at the document boundaries.

Context-sensitive Tab (escape only when the caret is at the very start or end, indent otherwise) sounds elegant and behaves unpredictably: whether Tab indents or leaves now depends on invisible caret state, so the user cannot form a reliable model of what the key does. Predictable beats clever for something this consequential.

Verifying it

Two assertions, and the second is the one that is easy to get wrong:

// Focus starts inside the editing area:
assert(document.activeElement === editorIframe);

editableBody.dispatchEvent(new KeyboardEvent("keydown",
  { key: "Escape", keyCode: 27, bubbles: true, cancelable: true }));

// ...and afterwards it is somewhere else entirely.
assert(document.activeElement !== editorIframe);
assert(document.activeElement.getAttribute("role") === "button");

Note both assertions read the hostdocument. We first wrote them against the iframe’s own document.activeElementand got a false failure: when an iframe’s window is not focused, its activeElement falls back to <body>, so it reports the same value whether focus is inside or gone. It cannot answer the question being asked.

Add the assertion to your suite rather than fixing it once. Keyboard traps are re-introduced by innocent changes — someone adds a Tab handler for a new table feature and the escape quietly stops firing. It is the kind of regression no visual test and no automated scanner will catch.

If you want the broader procedure this came out of, we wrote it up in auditing an editor for accessibility in an afternoon.


RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.