Using the contenteditable attribute

Table of contents

Instead of using a <textarea>, you can make any HTML element editable by setting the contenteditable attribute to true—see the MDN link and the specification link for more details on the attribute. This way, you can have an element that behaves like a textarea but it automatically resizes when the user types—something not possible with a textarea. This behavior can be useful for font-related applications, where you may not care what’s inside the editable element.

Uncontrolled component

If you’re using React, you can use a contenteditable element as an uncontrolled component. This means you don’t keep in the state the text that’s inside the editable element. The following snippet shows an example of that:

<p
  suppressContentEditableWarning
  contentEditable
  spellCheck={false}
>
  Edit this text
</p>

Instead of “Edit this text” you could render a children prop. This is what the code snippet above looks like when rendered in React:

Edit this text

If you don’t use the suppressContentEditableWarning, React will give you the following warning:

A component is contentEditable and contains children managed by React. It is now your responsibility to guarantee that none of those nodes are unexpectedly modified or duplicated. This is probably not intentional.

We are ok with the situation the warning describes because we don’t care what’s inside, so we suppress it.

If you want to have the resize handle at the bottom right of the element use the following CSS:

.editable {
  resize: both;
  overflow: auto;
}

The next element has a resize handle at the bottom right. It resizes when the user types until they drag the handle. At that point, it behaves like a textarea.

You can resize me

Pasting only text

If you try to paste something inside that element, you’ll notice that you can paste HTML elements, not only text. Try copying the “Pasting only text” heading into the previous editable element. If you want to paste only text, you can override the default behavior with an onPaste listener.

In the following snippet, I prevent the default element paste with e.preventDefault(), and I take only the text from the clipboard with the getData("text") method. Finally, I insert the text at the caret position with the document.execCommand:

import React from "react";

export default () => (
  <p
    suppressContentEditableWarning
    contentEditable
    spellCheck={false}
    onPaste={(e) => {
      e.preventDefault();
      const text = e.clipboardData.getData("text");
      document.execCommand("insertText", false, text);
    }}
  >
    Only text is allowed here.
  </p>
);

Try pasting a heading or a code block in the following paragraph. You’ll notice that you can paste only text.

Only text is allowed here.

The MDN link for execCommand warns us that this feature is now obsolete and could be removed at any time. You’d think that instead of using the execCommand, you can use a ref and directly change the DOM, as I do below:

import React, { useRef } from "react";

export default () => {
  const editableRef = useRef();
  return (
    <p
      suppressContentEditableWarning
      contentEditable
      ref={editableRef}
      spellCheck={false}
      onPaste={(e) => {
        e.preventDefault();
        const text = e.clipboardData.getData("text");
        editableRef.current.innerText = text;
      }}
    >
      Only text is allowed here.
    </p>
  );
};

But in this case, you replace the content every time, and, as a result, you lose the caret position because the content is brand new. This is what it looks like if you do that:

Probably not what you want.

Besides that, the undo command Ctrl + Z doesn’t work. If you want to implement the functionality of the insertText command, you have to re-invent the wheel—I have some code at the end with unsuccessful experiments. You have to account for the current selection to paste the text in the correct position, and after that, you have to place the caret at the expected position by creating a range. As a result, sticking with the obsolete functionality or using a <textarea> seems the way to go.

The support for e.clipboardData object doesn’t seem to be that great, at the time of writing at least. MDN warns us that it’s an experimental technology. I wonder if there is some error on caniuse.com, or if I can’t read the tables properly. For example, it says that’s not supported on desktop Safari 13, but I found that’s not the case when I tested. Anyway, an alternative is to use the Clipboard from the Navigator API which seems to have better support.

“Controlled” component

If you want to use the text of the editable element, you’ll have to keep it in the state. To update that state, you can use the onInput listener. But you’ll face the same problems I mentioned before with the onPaste listener. The problem is that you don’t have a value property for the editable element (as you have with inputs), nor an event.target.value on the event object of the onInput listener. If you replace the text content with a ref—mutate the DOM directly—you lose the caret position.

There is a small NPM package called react-contenteditable that approaches this problem in a different way. When the text content changes, you don’t replace the HTML content, but you call the user’s onChange listener and pass the current value so the user can update the state. You get that value with a ref, for example: ref.current.innerText. At the same time, you store that value in a variable outside the state. Because the user value (state) changes, the component will want to re-render. You prevent this with a shouldComponentUpdate method. This is the source code of the component on GitHub, and this an example on how to use it:

import React, { useState, useRef } from "react";
import ContentEditable from "react-contenteditable";

export default () => {
  const editableRef = useRef();
  const [editableText, setEditableText] = useState("Edit me.");
  return (
    <ContentEditable
      innerRef={editableRef}
      tagName="p"
      html={editableText}
      onPaste={(e) => {
        e.preventDefault();
        const text = e.clipboardData.getData("text");
        document.execCommand("insertText", false, text);
      }}
      onChange={(e) => {
        const html = e.target.value;
        setEditableText(html);
      }}
    />
  );
};

If you want to paste only text, you still have to implement the onPaste listener.

Final thoughts

Depending on your requirements, working with contentEditable elements in React can be challenging. Below, you can find more of my thoughts regarding the contentEditable attribute. If you feel I’m wrong somewhere or there is a better way of approaching the problem, let me know in the comments.

If your use case is to have text that when the user clicks inside, it becomes a contenteditable element, a better solution from an accessibility standpoint is to use a plain old input. Assuming you do this because you don't like the look of the input. If that's the case, you can use CSS to change the styling.
contenteditable elements can do much more; you can use them as rich text editors for example. Take a look at the MDN guide “Making Content Editable”.
You have to be aware of Cross-Site Scripting (XSS) attacks. If you allow the user to add HTML inside an element, and you then save that value in a server without sanitizing it, you may be vulnerable to XSS attacks. That is if you try to render again that content on another client. An example of that is comments from two different users on a post.

Implementing insertText (without success)

This is what I came up with while trying to implement the insertText command on the onPaste listener.

// onPaste listener
e.preventDefault();
const pastedText = e.clipboardData.getData("text");
const selection = window.getSelection();
const currentText = pRef.current.innerText;

// The idea was to create a range before
// I replace the text to keep track of
// the caret position. I add it to the
// selection aftewards. This doesn't work.
const range = document.createRange();
range.setStart(selection.anchorNode, selection.anchorOffset);
range.collapse(true);

// The selection is collapsed when the user
// has not selected any of the existing text.
// This works assuming the content is a single
// text node. If you press the enter to create
// new lines, that's not the case anymore and it
// doesn't work.
if (selection.isCollapsed) {
  const beforeCursor = currentText.slice(0, selection.anchorOffset);
  const afterCursor = currentText.slice(selection.anchorOffset);
  pRef.current.innerText = beforeCursor + pastedText + afterCursor;
} else {
  const selectionStart = currentText.slice(0, selection.anchorOffset);
  const selectionEnd = currentText.slice(selection.focusOffset);
  pRef.current.innerText = selectionStart + pastedText + selectionEnd;
}

selection.removeAllRanges();
selection.addRange(range);

Other things to read

Popular

Previous/Next