Illustration showing how to detect word wrap in textarea JavaScript using a hidden mirror div

How to Detect Word Wrap in a Textarea with JavaScript (No Libraries)

Ilyas elaissi
Ilyas Elaissi
10 min readMay 24, 2026

A textarea will not tell you when its text wraps. There is no wrap event, no callback, nothing. The browser draws the line break visually and moves on, and textarea.value stays exactly the same string you typed. If you need to know when content has flowed onto a new visual row, you have to measure it yourself, and the cleanest way to do that is with a hidden mirror element. This guide on how to detect word wrap in textarea JavaScript walks through the exact technique I use in production, with a working example you can paste in.

I have shipped this in two different rich-input components, and the trap most people fall into is trusting scrollHeight on the textarea directly. It works until someone resizes the window, or until your CSS has a weird padding value, and then your line counts drift by one. The mirror approach sidesteps all of that.

Why The Textarea Will Not Tell You Itself

Word wrap inside an <textarea> is purely a rendering concern. The DOM value is the raw string the user typed. If they hit Enter, you get a \n. If the browser soft-wraps a long sentence at column 47 because the box is 320 pixels wide, you get nothing. No newline, no event, no property flip.

This is the hard versus soft newline distinction. A hard newline lives in textarea.value. A soft wrap lives only in pixels. So the difference between what the user types and what the user sees, the value versus display problem, is the entire reason this is annoying to solve.

The input event fires on every keystroke, which is useful, but it fires whether wrapping happened or not. There is no built-in signal for the moment the line count changes. That gap between "text changed" and "visual layout changed" is the thing we have to bridge ourselves.

You will see suggestions on Stack Overflow that divide textarea.scrollHeight by lineHeight. That works, sort of, but it depends on the textarea actually having room to grow, and it gets confused by min-height, padding rounding, and any CSS that touches the box. I have hit all three of those bugs. The mirror is more reliable.

The Idea Behind The Hidden Mirror Element

Build a <div> that lies offscreen, give it the same width and font metrics as your textarea, dump the textarea's text into it, then read its height. Because the div uses the same wrapping rules, the same font, the same padding, and the same box-sizing, the browser will wrap the text the exact same way. Divide its height by the line height and you have your visual line count.

That is the whole trick. A second DOM element shadows the first, the browser does the wrapping math for free, and you read the result.

The reason this works is that text wrapping in CSS is deterministic given identical font metric matching, an identical content width, and the same white-space and overflow-wrap rules. Match those, and your mirror wraps where the textarea wraps. The pre-wrap white-space rule is what makes the div respect both real newlines and soft wrapping at word boundaries, which is exactly what a textarea does by default.

Why not Canvas 2D API or getClientRects

You can measure text with the Canvas 2D API's measureText, but you have to re-implement the wrapping algorithm yourself. Word breaks, Unicode segmentation, CJK rules, hyphens, all of it. Not worth it for this problem.

getClientRects on a Range inside a textarea also does not work the way you might hope, because textareas use a shadow tree the page cannot reach. You cannot put a Range on the internal text node. So a parallel mirror div is the pragmatic answer.

The Full Working Example

Here is the complete solution. It listens for the input event, recomputes the visual row count from the mirror, and updates a status line whenever the text wraps onto a new row.

HTML:

<textarea id="message" placeholder="Type a long sentence..."></textarea>
<p id="status">Lines: 1</p>

CSS:

textarea {
  width: 320px;
  min-height: 80px;
  padding: 10px;
  font-size: 16px;
  line-height: 24px;
  font-family: Arial, sans-serif;
  box-sizing: border-box;
  resize: none;
}

.mirror {
  position: absolute;
  visibility: hidden;
  white-space: pre-wrap;
  word-wrap: break-word;
  overflow-wrap: break-word;
  top: -9999px;
  left: -9999px;
}

JavaScript:

const textarea = document.getElementById("message");
const status = document.getElementById("status");

const mirror = document.createElement("div");
mirror.className = "mirror";
document.body.appendChild(mirror);

let previousLineCount = 1;

function copyTextareaStyles(textarea, mirror) {
  const styles = window.getComputedStyle(textarea);

  mirror.style.width = styles.width;
  mirror.style.fontFamily = styles.fontFamily;
  mirror.style.fontSize = styles.fontSize;
  mirror.style.fontWeight = styles.fontWeight;
  mirror.style.lineHeight = styles.lineHeight;
  mirror.style.letterSpacing = styles.letterSpacing;
  mirror.style.padding = styles.padding;
  mirror.style.border = styles.border;
  mirror.style.boxSizing = styles.boxSizing;
}

function getVisualLineCount(textarea) {
  copyTextareaStyles(textarea, mirror);

  mirror.textContent = textarea.value || " ";

  const styles = window.getComputedStyle(textarea);
  const lineHeight = parseFloat(styles.lineHeight);

  return Math.round(mirror.scrollHeight / lineHeight);
}

textarea.addEventListener("input", () => {
  const currentLineCount = getVisualLineCount(textarea);

  if (currentLineCount > previousLineCount) {
    status.textContent = `Word wrap detected. Lines: ${currentLineCount}`;
  } else {
    status.textContent = `Lines: ${currentLineCount}`;
  }

  previousLineCount = currentLineCount;
});

window.addEventListener("resize", () => {
  previousLineCount = getVisualLineCount(textarea);
});

Type a long enough sentence and the status line will say "Word wrap detected." Hit Enter mid-sentence and it just increments the count. The same code handles both, because both produce more rows in the mirror.

Vector concept of a hidden mirror div copying textarea styles to measure visual line count

Walking Through The Code

Three pieces are doing work here: the style-copying function, the measurement function, and the two event listeners.

The style copy

copyTextareaStyles reads the computed styles off the textarea and applies them to the mirror. Computed style copying matters because shorthand CSS like padding: 10px produces four resolved sub-properties, and window.getComputedStyle returns the resolved values. Browsers are consistent about returning these as pixel strings, which is what we want.

I am copying nine properties: width, font family, size, weight, line height, letter spacing, padding, border, and box-sizing. You may need to add font-variant, text-transform, or word-spacing if your design uses them. The principle is simple: anything that affects how text lays out has to match.

The width copy plus box-sizing: border-box on both is what keeps the content area identical. If your textarea uses border-box but your mirror falls back to content-box, the mirror's text area will be slightly wider, and wrap points will drift by a character or two on long lines. Consistent box-sizing is non-negotiable.

The measurement

getVisualLineCount is three lines that matter. Copy the styles. Set the mirror's textContent to the textarea value (with a fallback space so an empty string still gives height 1). Then divide the mirror's scrollHeight by the parsed line height and round.

scrollHeight works here because the mirror is not constrained by a min-height. It grows to fit its content exactly. The textarea, by contrast, often has a min-height set, which is why measuring it directly is unreliable. This is the cleanest way I have found to calculate the visual line count from the rendered height.

The Math.round matters. Browsers will sometimes return a scrollHeight of 47.5 pixels when the line-height is 24, because of sub-pixel rounding in font metrics. Rounding lands you on the integer the human eye sees.

The two listeners

The input listener handles every keystroke, paste, drag-drop, and IME composition commit. It is the right event for line detection tied to user typing. Compare the new count against the stored previous count, and you know whether wrapping just happened.

The resize listener handles the other half of the problem: the user has not typed anything, but the window got narrower and existing text wrapped onto more rows. We just refresh the baseline so the next keystroke does not falsely report a wrap event. If you want to react to wrap-on-resize too, this is the place to do it.

For more aggressive cases I have swapped the window resize handler for a ResizeObserver on the textarea itself. Resize observer handling is better when the textarea is inside a flex or grid container that can change width without the window changing size. The pattern is the same: recompute, store, move on.

👉 Also read: SSR vs CSR vs SSG vs ISR: Picking The Right Rendering Strategy

Edge Cases That Will Bite You

I have shipped this twice and both times something on this list broke me.

Custom fonts that load late. If your textarea uses a webfont, the first measurement runs against the fallback font, and your line counts will be wrong until the webfont swaps in. Fix it by re-measuring after document.fonts.ready resolves.

Scrollbars. If the textarea's height grows enough to need a vertical scrollbar, its content width shrinks by the scrollbar's width. The mirror, sitting offscreen, never has a scrollbar. Wrap points drift. The fix is to give the textarea overflow-y: scroll permanently so the scrollbar's width is always factored in, or to subtract the scrollbar width from the mirror.

Trailing newlines. A textarea with the value "hello\n" renders two rows. A div with textContent = "hello\n" may render one, because trailing whitespace handling differs slightly. If this matters, append a marker character and subtract its row, or read the height before and after a trailing space.

Right-to-left text and CJK. The mirror handles these correctly as long as direction and unicode-bidi are copied along with the other styles. I have not stress-tested mixed-direction content, so I would test that case specifically before relying on it.

Composition events. During IME composition (Japanese, Chinese, Korean input), input events fire mid-composition with provisional text. The line count may bounce around as the candidate window updates. If that visible bouncing is annoying, debounce the listener to fire on compositionend instead.

For the spec-level details on the events involved, the MDN Web Docs page for the input event is the right reference. It is also a good place to confirm which events fire during paste and drop, which both trigger wrapping changes.

Cheaper Alternatives And When They Are Fine

The mirror is the most reliable option, but it is not always necessary. Three lighter approaches I have used:

  • Divide textarea scrollHeight by lineHeight directly. Works if your textarea has no min-height, no padding rounding issues, and no scrollbar. About half the time, this is good enough. It also gives you a way to calculate the visual line count from scrollHeight without the second DOM node.
  • Count \n characters only. If you only care about hard newlines and not soft wraps, textarea.value.split("\n").length is the entire solution. Use this when "lines" means "what the user explicitly broke."
  • Auto-grow with CSS only. If your goal is just to make the textarea taller as the user types, field-sizing: content (Chromium-only at the time of writing) does it natively. No JavaScript needed. Check browser support before relying on it.

The mirror is what you want when you need an accurate count of rendered rows for things like virtual cursors, autocomplete positioning, or showing "your message will appear on N lines" to the user. For everything simpler, one of the above is less code.

Flat illustration of measuring textarea line height and visual line count with a ruler metaphor

Performance Notes From Production

A few things I have measured.

Reading window.getComputedStyle on every keystroke is fine. Modern browsers cache it, and it is a few microseconds at most. I tried memoizing it once and the code got uglier with no measurable win.

Setting mirror.textContent triggers a layout pass on the mirror, but because the mirror is position: absolute and offscreen, it does not invalidate the rest of the page. The cost stays local.

For very long textareas, think 5000+ character drafts, the mirror's layout starts to take a couple of milliseconds per keystroke. If you see input lag, debounce the measurement to roughly 16 ms with requestAnimationFrame. Do not throttle to longer than a frame or the UI feedback feels broken.

Do not put the mirror inside a CSS container that uses contain: layout or container-type. I lost an afternoon to that one. The wrapping behaved differently inside the contained context, and counts came out wrong.

A Tiny API Wrapper

If you are reaching for this in more than one component, wrap it. Something like:

function createLineCounter(textarea) {
  const mirror = document.createElement("div");
  Object.assign(mirror.style, {
    position: "absolute",
    visibility: "hidden",
    whiteSpace: "pre-wrap",
    overflowWrap: "break-word",
    top: "-9999px",
    left: "-9999px",
  });
  document.body.appendChild(mirror);

  function count() {
    const styles = window.getComputedStyle(textarea);
    ["width", "fontFamily", "fontSize", "fontWeight", "lineHeight",
     "letterSpacing", "padding", "border", "boxSizing"].forEach(p => {
      mirror.style[p] = styles[p];
    });
    mirror.textContent = textarea.value || " ";
    return Math.round(mirror.scrollHeight / parseFloat(styles.lineHeight));
  }

  function destroy() {
    mirror.remove();
  }

  return { count, destroy };
}

Now any component can do const counter = createLineCounter(ref.current) and call counter.count() whenever it wants. Remember to call destroy() on unmount so the mirror does not pile up in the DOM.

What I Would Do Differently Next Time

Two things.

First, I would put the mirror inside the same parent as the textarea, not on document.body. Same stacking context, same inherited styles, less drift. The offscreen positioning still hides it. The only reason I used body originally was that I was rushing.

Second, I would treat the line count as derived state and only compare deltas. The example above stores previousLineCount and updates the UI based on whether it grew. That is fine for a status message, but if you are driving real layout off this (like positioning an autocomplete popover), you want the absolute count every render, not a delta. Build the API to return the count and let the caller decide what to do with it.

The mirror approach is older than most modern JS frameworks and it still beats everything else for this specific problem. Browsers have not added a wrap event in twenty years and probably never will, because the wrapping algorithm is part of layout and layout does not emit events. So this is the technique. Copy it, adapt it, ship it.

Get CodeTips in your inbox

Free subscription for coding tutorials, best practices, and updates.