contenteditable in HTML: make any element typeable

One attribute makes any element editable on click. contenteditable does not save, does not validate, and does not tell anyone it is there, so the work is in what you add around it: a visible focus style, a place for the text to go, and a guard against the empty-element trap.

contenteditable in HTML is the attribute that turns any element into a text field the moment it is clicked: <td contenteditable="true"> and the cell takes typing. No library, no framework, every browser.

The markup. The highlighted line is the part this term is about.
The markup. The highlighted line is the part this term is about.

It is the least-known useful attribute in HTML, and it comes with three things it does not do: it does not save, it does not validate, and it gives no visual hint that it exists.

This guide covers the three, the focus style that makes it discoverable, the empty-element trap, paste handling, and why a full document editor should not be built from it alone.

<td contenteditable="true">In review</td>

That cell can now be clicked and typed into. The browser provides the caret, selection, undo, and spell-check. No library, no script, no framework.

It works on anything — a heading, a paragraph, a table cell, a whole document:

<h1 contenteditable="true">Click me and type</h1>

And from the console, on an entire page:

document.body.contentEditable = true

Useful for trying wording out on a page you are reviewing.

The three things contenteditable does not do

1. It does not save

One attribute turns any element into a text field on click. contenteditable does not save and does not validate; a visible focus style is what makes people discover it.
One attribute turns any element into a text field on click. contenteditable does not save and does not validate; a visible focus style is what makes people discover it.

Reload and the edits are gone. Saving means reading the content out and storing it:

<script>
  var cell = document.querySelector('[contenteditable]');
  cell.addEventListener('input', function () {
    try { localStorage.setItem('note', cell.textContent); } catch (e) {}
  });
</script>

The try is required, not defensive. Local storage throws in private browsing and inside sandboxed frames, and an unguarded exception halts the rest of your script.

And local storage belongs to one browser on one machine — so a file sent to four people produces four divergent copies. That limit is the whole reason a shared table needs one address rather than a better file.

2. It does not validate

A cell that should hold a date will accept "next Tuesday-ish". If the value matters, check it:

<script>
  cell.addEventListener('blur', function () {
    var ok = /^\d{4}-\d{2}-\d{2}$/.test(cell.textContent.trim());
    cell.style.background = ok ? '' : '#fff1f1';
  });
</script>

3. It does not restrict what gets pasted

A paste inserts HTML, so pasting from a word processor brings fonts, colours and inline styles into your page. Strip it:

<script>
  cell.addEventListener('paste', function (e) {
    e.preventDefault();
    var text = (e.clipboardData || window.clipboardData).getData('text/plain');
    document.execCommand('insertText', false, text);
  });
</script>

Make it visible

The most common mistake is having no focus style. Clicking a cell then produces no visible change, and nobody discovers the element is editable at all:

[contenteditable]:focus {
  outline: 2px solid #d5f525;
  outline-offset: -2px;
  background: #fcffe8;
}

Two additions worth making for anything non-obvious:

<td contenteditable="true" role="textbox" aria-label="Status">In review</td>

role="textbox" and a label are what make it announced as an editable field rather than as text. See aria-label.

A copy per person ✗ Each edit lives on one machine ✗ No way to merge the changes ✗ Nobody can say which is current ✗ The oldest copy keeps circulating One address ✓ Everyone opens the same page ✓ A correction is seen by all ✓ There is only one current version ✓ Forwarding shares the page, not a copy
Edits stored next to each reader produce four tables that cannot see each other. Edits on one page at one address produce one.

Empty-element trap

An empty editable element collapses to zero height and becomes unclickable. Give it a floor:

[contenteditable]:empty::before {
  content: attr(data-placeholder);
  color: #a1a1aa;
}
[contenteditable] { min-height: 1.4em; }

Where it is the right tool

Single values: table cells, headings, labels, short notes. There the browser's built-in behaviour is all you need and the code is one attribute.

Where it is the wrong tool is a full document editor. Selection handling, undo grouping and paste normalisation differ between browsers in ways that take months to reconcile — which is why editors are built on libraries that spend their lives on exactly that problem.

In NOS the rendered page's text is editable and stored, so the correction one person makes is the page everybody sees. That is the part contenteditable alone cannot provide — see editing generated HTML without code.

What you have to build yourself

Behaviour Provided by the browser You provide
Caret, selection, undo Yes
Spell check Yes
Typing, deleting Yes
Saving Storage, with a try
Validation A blur handler
Plain-text paste A paste handler
A visible focus state One CSS rule
An accessible name role and a label

The first three are why the attribute is worth using at all — reimplementing a caret is a project. The bottom five are each a few lines, and skipping the focus state is the one that makes the feature undiscoverable.

Reading the value back out

<script>
  var text = el.textContent;   // plain text, no markup
  var html = el.innerHTML;     // markup, including anything pasted
</script>

Use textContent unless you specifically want markup. innerHTML on an editable element returns whatever the browser produced — which varies between browsers for the same typing, and includes any styling that survived a paste.

If you are storing the value anywhere it will be rendered again, textContent also removes the question of what happens when someone pastes a script tag.

Three contenteditable mistakes that cost the most

No focus style. The attribute works, and nobody knows. Clicking the cell changes nothing visible, so readers assume it is plain text. One :focus rule with an outline fixes it.

Trusting the typed value. A cell meant to hold a date will hold "next week-ish". If the value feeds a calculation, parse it and guard against the empty or non-numeric case, or use an input element where a real field is needed.

Assuming edits are shared. They are not. Each browser keeps its own copy at best, so the table sent to four people becomes four tables. If the rows are shared, the page has to live at one address where the edit lands on the same document for everyone.

Where contenteditable is the right tool, and where it is not

Right: a table cell, a caption, a figure in a card, any short piece of text where clicking and typing is the natural gesture. Wrong: a whole document editor.

Browsers disagree on what pressing Enter or pasting produces inside a large editable region, and every serious editor is built on top of the attribute with a great deal of correction code, not on the attribute alone.

Use it for cells and lines; use a real editor for documents.

Using contenteditable: 4 steps

  1. Add the attribute to the element. contenteditable="true" on a cell, a heading, a paragraph.
  2. Add a visible focus style. An outline on :focus. Without it, nobody discovers the element is editable, and the feature might as well not exist.
  3. Decide where the text goes. Local storage for one person on one machine; a page at one address for more than one, which is what the editable table builder sets up.
  4. Handle paste and emptiness. Strip formatting on paste so a copied heading does not arrive as a heading, and keep a minimum height so an emptied cell can still be clicked.

Questions people ask

What does contenteditable do?

It lets the reader click into an element and type. The browser handles the caret, selection, and undo — there is no JavaScript involved in the editing itself.

Does it save the changes?

No. Reload and everything typed is gone. Saving requires reading the content out and storing it somewhere.

Why does pasted text bring formatting with it?

Because a paste inserts HTML by default. Intercept the paste event and insert plain text instead.

Should I use it for a large editor?

Not from scratch. Selection, undo and paste behaviour diverge between browsers in ways that take a long time to reconcile. For cells and headings it is ideal.

Keep reading