惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

D
Docker
爱范儿
爱范儿
人人都是产品经理
人人都是产品经理
博客园 - 司徒正美
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
量子位
罗磊的独立博客
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
小众软件
小众软件
C
Cybersecurity and Infrastructure Security Agency CISA
Cyberwarzone
Cyberwarzone
大猫的无限游戏
大猫的无限游戏
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
雷峰网
雷峰网
Simon Willison's Weblog
Simon Willison's Weblog
The Cloudflare Blog
博客园 - 三生石上(FineUI控件)
D
Darknet – Hacking Tools, Hacker News & Cyber Security
C
Cyber Attacks, Cyber Crime and Cyber Security
博客园_首页
博客园 - 叶小钗
V
Vulnerabilities – Threatpost
T
The Exploit Database - CXSecurity.com
T
Tailwind CSS Blog
IT之家
IT之家
博客园 - 聂微东
Spread Privacy
Spread Privacy
V2EX - 技术
V2EX - 技术
S
Security Affairs
宝玉的分享
宝玉的分享
V
V2EX
C
Cisco Blogs
博客园 - Franky
美团技术团队
酷 壳 – CoolShell
酷 壳 – CoolShell
月光博客
月光博客
S
Securelist
J
Java Code Geeks
Webroot Blog
Webroot Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
P
Proofpoint News Feed
Last Week in AI
Last Week in AI
L
LINUX DO - 热门话题
NISL@THU
NISL@THU
WordPress大学
WordPress大学
W
WeLiveSecurity
T
Threatpost
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
腾讯CDC
阮一峰的网络日志
阮一峰的网络日志

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
How to Detect Word Wrap in Textarea JavaScript
ilyas Elaiss · 2026-05-26 · via DEV Community

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

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>

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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);
});

Enter fullscreen mode Exit fullscreen mode

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.

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.

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 };
}

Enter fullscreen mode Exit fullscreen mode

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.