Note: This is not a duplicate as far as I can tell, as using a contentEditable
div doesn't seem to be a good alternative. It has numerous problems (no placeholder text, need to use the dangerouslySetInnerHTML
hack to update text, selection cursor is finicky, other browser issues, etc.) I would like to use a textarea.
I'm currently doing something this for my React textarea component:
componentDidUpdate() {
let target = this.textBoxRef.current;
target.style.height = 'inherit';
target.style.height = `${target.scrollHeight + 1}px`;
}
This works and allows the textarea to dynamically grow and shrink in height as line breaks are added and removed.
The problem is that on every text change there is a reflow occurring. This causes a lot of lag in the application. If I hold down a key in the textarea there is delay and lag as the characters are appended.
If I remove the target.style.height = 'inherit';
line the lag goes away, so I know it's being caused by this constant reflow.
I heard that setting overflow-y: hidden
might get rid of the constant reflow, but it did not in my case. Likewise, setting target.style.height = 'auto';
did not allow for dynamic resize.
I currently have developed a solution to this which works, but I don't like it, as it is an O(n) operation for every time the text changes. I just count the number of line breaks and set the size accordingly, like this:
// In a React Component
handleMessageChange = e => {
let breakCount = e.target.value.split("\n").length - 1;
this.setState({ breakCount: breakCount });
}
render() {
let style = { height: (41 + (this.state.breakCount * 21)) + "px" };
return (
<textarea onChange={this.handleMessageChange} style={style}></textarea>
);
}
Note: This is not a duplicate as far as I can tell, as using a contentEditable
div doesn't seem to be a good alternative. It has numerous problems (no placeholder text, need to use the dangerouslySetInnerHTML
hack to update text, selection cursor is finicky, other browser issues, etc.) I would like to use a textarea.
I'm currently doing something this for my React textarea component:
componentDidUpdate() {
let target = this.textBoxRef.current;
target.style.height = 'inherit';
target.style.height = `${target.scrollHeight + 1}px`;
}
This works and allows the textarea to dynamically grow and shrink in height as line breaks are added and removed.
The problem is that on every text change there is a reflow occurring. This causes a lot of lag in the application. If I hold down a key in the textarea there is delay and lag as the characters are appended.
If I remove the target.style.height = 'inherit';
line the lag goes away, so I know it's being caused by this constant reflow.
I heard that setting overflow-y: hidden
might get rid of the constant reflow, but it did not in my case. Likewise, setting target.style.height = 'auto';
did not allow for dynamic resize.
I currently have developed a solution to this which works, but I don't like it, as it is an O(n) operation for every time the text changes. I just count the number of line breaks and set the size accordingly, like this:
// In a React Component
handleMessageChange = e => {
let breakCount = e.target.value.split("\n").length - 1;
this.setState({ breakCount: breakCount });
}
render() {
let style = { height: (41 + (this.state.breakCount * 21)) + "px" };
return (
<textarea onChange={this.handleMessageChange} style={style}></textarea>
);
}
Share
Improve this question
edited Sep 16, 2019 at 22:56
Ryan Peschel
asked Sep 16, 2019 at 22:48
Ryan PeschelRyan Peschel
11.8k22 gold badges87 silver badges161 bronze badges
8
|
Show 3 more comments
5 Answers
Reset to default 8I think thirtydot's recommendation may be the best. The Material UI textarea he linked has a pretty clever solution.
They create a hidden absolutely positioned textarea that mimics the style and width of the actual textarea. Then they insert the text you type into that textarea and retrieve the height of it. Because it is absolutely positioned there is no reflow calculation. They then use that height for the height of the actual textarea.
I don't fully understand all of what their code is doing, but I've hacked together a minimal repurposement for my needs, and it seems to work well enough. Here are some snippets:
.shadow-textarea {
visibility: hidden;
position: absolute;
overflow: hidden;
height: 0;
top: 0;
left: 0
}
<textarea ref={this.chatTextBoxRef} style={{ height: this.state.heightInPx + "px" }}
onChange={this.handleMessageChange} value={this.props.value}>
</textarea>
<textarea ref={this.shadowTextBoxRef} className="shadow-textarea" />
componentDidUpdate() {
this.autoSize();
}
componentDidMount() {
this.autoSize();
}
autoSize = () => {
let computedStyle = window.getComputedStyle(this.chatTextBoxRef.current); // this is fine apparently..?
this.shadowTextBoxRef.current.style.width = computedStyle.width; // apparently width retrievals are fine
this.shadowTextBoxRef.current.value = this.chatTextBoxRef.current.value || 'x';
let innerHeight = this.shadowTextBoxRef.current.scrollHeight; // avoiding reflow because we are retrieving the height from the absolutely positioned shadow clone
if (this.state.heightInPx !== innerHeight) { // avoids infinite recursive loop
this.setState({ heightInPx: innerHeight });
}
}
A bit hacky but it seems to work well enough. If anyone can decently improve this or clean it up with a more elegant approach I'll accept their answer instead. But this seems to be the best approach considering Material UI uses it and it is the only one I've tried so far that eliminates the expensive reflow calculations that cause lag in a sufficiently complex application.
Chrome is only reporting reflow occurring once when the height changes, as opposed to on every keypress. So there is still a single 30ms lag when the textarea grows or shrinks, but this is much better than on every key stroke or text change. The lag is 99% gone with this approach.
NOTE: Ryan Peschel's answer is better.
Original Post: I have heavily modified apachuilo's code to achieve the desired result. It adjusts the height based on the scrollHeight
of the textarea
. When the text in the box is changed, it sets the box's number of rows to the value of minRows
and measures the scrollHeight
. Then, it calculates the number of rows of text and changes the textarea
's rows
attribute to match the number of rows. The box does not "flash" while calculating.
render()
is only called once, and only the rows
attribute is changed.
It took about 500ms to add a character when I put in 1000000 (a million) lines of at least 1 character each. Tested it in Chrome 77.
CodeSandbox: https://codesandbox.io/s/great-cherry-x1zrz
import React, { Component } from "react";
class TextBox extends Component {
textLineHeight = 19;
minRows = 3;
style = {
minHeight: this.textLineHeight * this.minRows + "px",
resize: "none",
lineHeight: this.textLineHeight + "px",
overflow: "hidden"
};
update = e => {
e.target.rows = 0;
e.target.rows = ~~(e.target.scrollHeight / this.textLineHeight);
};
render() {
return (
<textarea rows={this.minRows} onChange={this.update} style={this.style} />
);
}
}
export default TextBox;
While it's not possible to eliminate all reflows — the browser has to calculate the height at some point — it is possible to reduce them significantly.
Per Paul Irish (a Chrome developer), elem.scrollHeight
is among the property accesses & methods that cause a reflow. However, there is a significant note:
Reflow only has a cost if the document has changed and invalidated the style or layout. Typically, this is because the DOM was changed (classes modified, nodes added/removed, even adding a psuedo-class like :focus).
This is where, for plain text, a textarea is actually superior to a <div contenteditable>
. For a div, typing changes the innerHTML
, which is actually a Text
node. As such, modifying the text in any way also modifies the DOM, causing a reflow. In the case of a textarea, typing only changes its value
property — nothing touches the DOM, all that's required is repainting, which is (comparatively) very cheap. This allows the rendering engine to cache the value as indicated by the above quote.
Because of the browser's cacheing of scrollHeight
, you can use the "classic" advice — fetch that value and immediately set it to the actual height.
function resizeTextarea(textarea) {
textarea.style.height = 'auto';
textarea.style.height = `${textarea.style.scrollHeight}px`;
}
Use that method any time the value changes, which will ensure the textarea remains at a height that does not scroll. Don't worry about the consecutive setting of the property, as the browser executes these together (similar to requestAnimationFrame
).
This is true in all WebKit-based browsers, which are currently Chrome and Opera, and soon to be Edge as well. I presume Firefox and Safari have similar implementations.
Personally I couldn't image reading all those line breaks like that being too much of a problem unless your writing a novel, but I don't know. You could try adjusting the number of breaks based on keystroke.
Sandbox here.
import React, { Component } from "react";
class TextBox extends Component {
state = {
breakCount: 0
};
handleKeyDown = e => {
if (e.key === "Enter") {
this.setState({ breakCount: this.state.breakCount + 1 });
}
// Note you will want something to better detect if a newline is being deleted. Could do this with more logic
// For quick testing of spamming enter/backspace key though this works.
if (e.key === "Backspace" && this.state.breakCount > 0) {
this.setState({ breakCount: this.state.breakCount - 1 });
}
};
render() {
const style = { height: 41 + this.state.breakCount * 21 + "px" };
return <textarea onKeyDown={this.handleKeyDown} style={style} />;
}
}
export default TextBox;
A "modern" hooks-apporach only using built-in features of react would be useRef and useLayoutEffects. This approach updates the height of the textarea triggered by the change of value before any rendering in the browser and therefor avoids any flickering/jumping of the textarea.
import React from "react";
const MIN_TEXTAREA_HEIGHT = 32;
export default function App() {
const textareaRef = React.useRef(null);
const [value, setValue] = React.useState("");
const onChange = (event) => setValue(event.target.value);
React.useLayoutEffect(() => {
// Reset height - important to shrink on delete
textareaRef.current.style.height = "inherit";
// Set height
textareaRef.current.style.height = `${Math.max(
textareaRef.current.scrollHeight,
MIN_TEXTAREA_HEIGHT
)}px`;
}, [value]);
return (
<textarea
onChange={onChange}
ref={textareaRef}
style={{
minHeight: MIN_TEXTAREA_HEIGHT,
resize: "none"
}}
value={value}
/>
);
}
https://codesandbox.io/s/react-textarea-auto-height-s96b2
debounce
with a wait of 166ms, so it doesn't reflow constantly. And the hidden "shadow"<textarea>
. – thirtydot Commented Sep 16, 2019 at 23:14target.style.height
) – Ryan Peschel Commented Sep 19, 2019 at 6:13