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 containschildren
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.
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.
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”.Related links
- Stackoverflow thread from 2014; React is old I guess.
- Which elements can be safely made contenteditable?
- The autocapitalize attribute.
- CSS Tricks: Show and Edit Style Element.
- Tania Rascia: Using Content Editable Elements in JavaScript (React).
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
- Reveal animations on scroll with react-spring
- Gatsby background image example
- Extremely fast loading with Gatsby and self-hosted fonts