
How layout, hidden elements, and line breaks change the text JavaScript returns.
For years, I treated innerText and textContent as interchangeable.
Browser compatibility encouraged that assumption. Older projects often used expressions such as dom.innerText || dom.textContent, and eventually I stopped thinking about why two properties existed.
Then an absolutely positioned <span> produced an unexpected line break.
That small surprise exposed a distinction I had overlooked: innerText accounts for rendering; textContent reads text from the DOM.
Two Properties, Two Different Questions
When choosing between them, start with what you want to retrieve:
| Property | What it answers | Available on |
|---|---|---|
innerText | What text does this element represent when rendered? | HTMLElement |
textContent | What text is stored in this node and its descendants? | Node |
For an element, textContent concatenates descendant text without interpreting layout. Some other node types, including documents and doctypes, return null. MDN: textContent
The following excerpts come from the accompanying interactive demo. They use its existing controls and output elements, with presentation code omitted.
1. Layout Can Become a Line Break
The demo begins with this markup: <p data-source>Some text<span style="position:absolute;">...</span></p>.
The dots can appear immediately after the words. Yet absolute positioning blockifies the span, changing its computed display behavior. That can introduce a newline into innerText, even though there is no newline in the source text.
The layout experiment lets you switch between absolute positioning, inline display, and block display:
const root = document.getElementById("layout-demo");
const source = root.querySelector("[data-source]");
const span = source.querySelector("span");
const mode = root.querySelector("[data-mode]");
function update() {
span.style.position = mode.value === "absolute" ? "absolute" : "static";
span.style.display = mode.value === "block" ? "block" : "inline";
root.querySelector("[data-inner]").textContent =
JSON.stringify(source.innerText);
root.querySelector("[data-text]").textContent =
JSON.stringify(source.textContent);
root.querySelector("[data-display]").textContent =
"Span computed display: " + getComputedStyle(span).display;
}
mode.addEventListener("change", update);
update();
The useful debugging detail is JSON.stringify(): it makes newline characters visible as \n.
Without that, a newline displayed in an ordinary HTML container may collapse into a space. You could mistake a formatting difference in the output for a literal space in the returned string.
The demo’s whitespace experiment explores the same distinction. With white-space: normal, innerText collapses the spaces and source newline in Hello curious\nworld.. Switching to pre-wrap preserves them. textContent stays unchanged because the text nodes have not changed.
2. Hidden Text Is Still in the DOM
The second experiment uses a visible paragraph containing a hidden span:
<p data-source>There is some hidden text after me<span hidden>—that's me!</span></p>
The checkbox toggles the span’s hidden property, then reads the paragraph again:
const root = document.getElementById("hidden-demo");
const source = root.querySelector("[data-source]");
const reveal = root.querySelector("[data-reveal]");
function update() {
source.querySelector("span").hidden = !reveal.checked;
root.querySelector("[data-inner]").textContent =
JSON.stringify(source.innerText);
root.querySelector("[data-text]").textContent =
JSON.stringify(source.textContent);
}
reveal.addEventListener("change", update);
update();
While the span is hidden, the paragraph’s innerText excludes its text. textContent still includes it. Reveal the span, and both readouts include the complete sentence.
There is an important boundary: innerText is not a universal visibility filter. If the element being read is itself not rendered—for example, it is detached or has display: none—the getter returns its descendant text content. Reading a hidden child directly can therefore differ from reading its rendered parent. HTML Standard: innerText
3. Writing Text Also Produces Different Results
The distinction matters when assigning values, too.
Both setters replace an element’s children and treat markup characters as text. But innerText turns newline characters into <br> elements, while textContent stores them in a text node. MDN: innerText
The writing experiment appends a second line to the input, assigns the same string through both properties, and displays the resulting HTML:
const root = document.getElementById("writing-demo");
const input = root.querySelector("input");
const innerResult = root.querySelector("[data-inner-result]");
const textResult = root.querySelector("[data-text-result]");
function update() {
const value = input.value + "\nSecond line";
innerResult.innerText = value;
textResult.textContent = value;
root.querySelector("[data-inner-html]").textContent =
JSON.stringify(innerResult.innerHTML);
root.querySelector("[data-text-html]").textContent =
JSON.stringify(textResult.innerHTML);
}
root.querySelector("[data-write]").addEventListener("click", update);
update();
Try the default input, <b>Hello</b>. Both panels show the tags literally; neither makes “Hello” bold.
With the demo’s white-space: normal, the innerText result displays two lines because it contains a <br>. The textContent result contains a newline that collapses during rendering. Apply white-space: pre-wrap when you want text-node newlines to remain visible.
The example reads innerHTML only to inspect the resulting structure. It writes those readouts using textContent, keeping the markup visible as text.
Performance: Rendering Awareness Has a Cost
Reading innerText may force the browser to update style and layout when rendering information is out of date. Reading textContent does not require layout.
That does not mean every innerText read triggers a fresh reflow. The cost depends on the browser’s current state, and repeated reads mixed with layout-changing writes deserve particular attention. Mozilla implementation discussion
Choose the property for its behavior first. When you only need DOM text, textContent also avoids unnecessary dependence on rendering.
Retire the Compatibility Habit
The old fallback dom.textContent || dom.innerText has a subtle problem: an empty string is a valid result, but || treats it as a reason to use the other property.
If you maintain legacy code, test property availability explicitly—for example, with "textContent" in dom. That detects support, although it cannot make the two properties behave identically.
Historical Internet Explorer quirks should be treated as legacy implementation details, rather than the definition of either API.
Which Should You Use?
Use textContent when you need descendant text regardless of presentation, or when you want to replace an element’s contents with plain text.
Use innerText when your requirement specifically depends on rendered text, including its treatment of layout and hidden descendants.
For plain-text insertion, prefer textContent over innerHTML: it expresses your intent directly and does not interpret the string as HTML. MDN: textContent
The mistake was assuming that two properties returning strings must answer the same question. Once rendering enters the picture, they answer different ones.
Try It Yourself
Want to see these concepts in action? I’ve created an interactive demo where you can experiment with the code and see real-time results.
Explore more demos from my previous articles in the Demo Gallery.
Happy coding!