Invisible text: two different things, and only one of them is zero-width

Short answer: invisible text is text made of characters that paint no ink but still hold a position in the string. There are two families, and they behave in opposite ways. Unicode format characters — general category Cf — measure 0.00 px in Chromium 153, and find-in-page ignores them in both directions: you cannot search for one, and one sitting inside a word does not stop you finding that word. The characters most blank-text pages hand out instead, the Hangul fillers such as U+3164, are letters (category Lo). They paint nothing either, but each one measures 16.00 px at 16px system-ui — wider than a capital A at 11.27 px. Both kinds survive copy and paste completely, and both are handed to the accessibility layer unchanged.

If the search was blank text copy and paste, that distinction is the one that decides how it lands: a format character pastes as nothing and leaves the line exactly as it was, while a Hangul filler pastes as nothing and still takes a full character of width, so the text around it moves.

The two families, side by side

Everything below was measured in Chromium 153 on Windows. Widths come from rendering |X| and subtracting the width of ||, at 16px system-ui. The category column comes from Python 3.13 (unicodedata, Unicode 15.1.0).

CharacterCategoryPainted inkAdvance width
U+200B ZERO WIDTH SPACECf0 px0.00 px
U+00AD SOFT HYPHENCf0 px0.00 px
U+FEFF ZERO WIDTH NO-BREAK SPACECf0 px0.00 px
U+2060 WORD JOINERCf0 px0.00 px
U+200D ZERO WIDTH JOINERCf0 px0.00 px
U+180E MONGOLIAN VOWEL SEPARATORCf0 px0.00 px
U+E0020 TAG SPACECf0 px0.00 px
U+3164 HANGUL FILLERLo0 px16.00 px
U+1160 HANGUL JUNGSEONG FILLERLo0 px16.00 px
U+115F HANGUL CHOSEONG FILLERLo0 px16.00 px
U+FFA0 HALFWIDTH HANGUL FILLERLo0 px8.00 px
U+2800 BRAILLE PATTERN BLANKSo0 px10.42 px
U+0041 LATIN CAPITAL LETTER ALu247 px11.27 px

Ink was counted on a canvas at 32px: the letter A lights up 247 pixels, every character in this table lights up zero. That is the whole trick, and it is also the whole problem — "paints nothing" and "takes no space" are two different properties, and only the Cf family has both. A run of 1,000 zero-width spaces measures 0.00 px; a run of 1,000 Hangul fillers measures over 16,000. Only one of those is invisible in the sense people actually mean.

You cannot search for it, and it does not hide what is around it

This is the part that surprises people. Find-in-page does not treat invisible characters as characters at all — it treats them as absent. Measured with window.find() in Chromium 153:

Searching for this character on its ownFound?
U+200B ZERO WIDTH SPACEno
U+00AD SOFT HYPHENno
U+FEFF ZERO WIDTH NO-BREAK SPACEno
U+2060 WORD JOINERno
U+200D ZERO WIDTH JOINERno
U+180E MONGOLIAN VOWEL SEPARATORno
U+E0020 TAG SPACEno
U+2028 LINE SEPARATORyes
U+3164 HANGUL FILLERyes
U+1160 HANGUL JUNGSEONG FILLERyes
U+FFA0 HALFWIDTH HANGUL FILLERyes
U+2800 BRAILLE PATTERN BLANKyes

So the usual advice — press Ctrl+F and search for the character to see where it is — does not work for any format character. Note where the line falls: U+2028 is a line separator, not a format character, and the Hangul fillers are letters, so all of them are findable. Now the other direction, which is where it gets strange. Put one inside a word and search for the word:

Character insideQueryMatchSelection returned
U+200BHelloyes6 code points, invisible one included
U+200BHellyes5 code points
U+200BHelxono—
U+FEFFHelloyes6 code points, invisible one included
U+E0020Helloyes6 code points, invisible one included
U+2028Hellono—
U+00A0Hellono—
U+0041, a real XHellono—

The control rows matter. Searching Helxo against the same text fails, and searching Hello against HeXllo fails, so this is not a loose match. Chromium genuinely skips format characters when it compares, which means a hidden message buried in a word does not break search for that word — but it also means the search box is useless as a detector. Replace the zero-width space with U+2028 or a no-break space and the match fails again: those are separators, not format characters, and they are not skipped.

Which ones break a line, and which ones do not

People reach for invisible text to make long unbreakable strings wrap, and to stop two words being read as one. Only some of these characters do either job. Measured in a 60px box holding AAAAA<char>BBBBB; with no character at all it lays out as one line, 20px tall, 106px of overflow:

CharacterLine boxesHeightOverflow width
U+200B240 px60 px
U+00AD340 px63 px
U+2028240 px61 px
U+FEFF120 px106 px
U+2060120 px106 px
U+200D120 px106 px
U+180E120 px106 px
U+E0020120 px106 px
U+00A0120 px111 px

The Unicode names are honest here, which is rare. U+FEFF is called ZERO WIDTH NO-BREAK SPACE and it does not offer a break point. U+2060 is called WORD JOINER and it does not offer one either. If you are trying to get a long URL to wrap in a comment box, four of the seven format characters tested will do nothing at all — only U+200B and U+00AD help, and U+00AD draws a hyphen when it breaks, which is why its overflow is 63px rather than 60px.

Which ones split a word

Word segmentation is what decides what a double-click selects, where the cursor stops when you hold Ctrl and press an arrow, and what a tokenizer hands to a search index. Measured with word-granularity selection on hello<char>world:

SeparatorWord stops atDouble-click selects
space U+00200, 6, 11“hello ”
U+200B0, 5, 11“hello”
U+20280, 6, 11“hello” plus the separator
U+00A00, 6, 11“hello” plus the space
U+FEFF0, 11all 11 code points, one word
U+00AD0, 11all 11 code points, one word
U+20600, 11all 11 code points, one word
U+200D0, 11all 11 code points, one word
U+E00200, 12all 11 code points, one word
U+31640, 11all 11 code points, one word

U+200B is the odd one out: it splits the word exactly like a normal space does, and it is the only format character here that does. U+3164 behaves like a letter, because it is one — double-clicking helloㅤworld takes the whole thing. So two "invisible" separators that look identical in a textarea give a search index completely different tokens.

Your cursor goes through them without moving

Place the caret at the start of a line and press the right arrow once per character. Across five capital A's the caret advances 31.25, 42.52, 53.77, 65.03 and 76.28 px — 11.25 px a step. Across five zero-width spaces it sits at 20 px for every single step, including the last one. The same holds for U+FEFF. There is no visual signal at all that five characters went past, which is why a line containing invisible text can feel like it has a dead key in the middle of it, and why retyping a suspicious string by hand works when editing it does not.

It copies perfectly, and it reaches the screen reader

Two claims about invisible text are usually made in opposite directions: that it gets lost in transit, and that it is the most durable thing in the string. Measured, the second one is right. Selecting a fifteen-code-point run carrying four embedded invisible characters and reading the clipboard back returned all fifteen code points with all four intact, for every character tested — U+200B, U+00AD, U+FEFF, U+2060, U+200D, U+180E, the astral U+E0020, and U+2028. Nothing was dropped, and nothing was normalised away. The browser's own notion of rendered text keeps them too: innerText and textContent came back identical at thirteen code points, zero dropped. That is the clipboard road. A form is a different road with different arithmetic, and it is measured separately below.

The accessibility layer is where the disagreement usually sits, and it is a disagreement about different layers rather than about facts. Chromium does not filter these characters before handing text to assistive technology: read the full accessibility tree for the string Hello + the character + World, and the static text node still carries the raw code point, for all eight characters tested. So the browser passes the code points through verbatim, and what happens next belongs entirely to the screen reader — one reader may say nothing, another spells the code point out, and either behaviour is consistent with what was measured here.

A form sends a different string than the one your script reads

Copy is one way invisible text leaves a page. A form is another, and a form is two things at once: the value JavaScript can read out of the element, and the value that actually reaches the server. Measured in Chromium 153 by posting a real form to a local server and reading the raw request body, the two are not the same string. A textarea holding a + U+000A + b reports a, U+000A, b through textarea.value, and sends v=a%0D%0Ab — a carriage return and a line feed. Put a + U+000D + U+000A + b into the same field and the readable value collapses to a single U+000A, while the body still carries one CRLF pair, not two.

The set behind the next table is 52 code points: every character in the table at the top of this page, plus the control and separator characters that turn up in text pasted from somewhere else — U+0000, U+0009, U+000B, U+000C, U+001C to U+001F, U+007F, U+0085, U+009F, U+2028, U+2029, U+202F and U+3000. One code point at a time, through four roads out of the page:

Road outWhat it does to the 52Which ones change
textarea.value, read by JavaScript51 of 52 unchangedU+000D becomes U+000A
The same textarea, submitted50 of 52 unchangedU+000A and U+000D both leave as CRLF
A single-line <input>50 of 52 unchangedU+000A and U+000D are deleted
A textarea with wrap="hard"Adds line breaks nobody typedCRLF every cols + 2 characters

The last row is the odd one, because none of it is a character anybody typed. A textarea with wrap="hard" and cols="4", holding 26 letters and no break at all, went out as four chunks joined by CRLF — a line break every six characters — while textarea.value was still the same 26 letters with nothing between them. cols="8" produced a break every ten, cols="20" every 22, and cols="40" inserted none at all, because 26 letters fit. Hard wrapping is not the default; soft wrapping, which most people leave in place, does not do this.

A single-line field is narrower than it looks. It deletes exactly two of the 52 — U+000A and U+000D — and hands all fifty others back untouched, including U+000B, U+000C, U+0085, U+2028 and U+2029, the last two being the characters a browser will not break a line at under any setting, which the line separator page measures. A box that cannot show a line break is not a filter for line breaks: it filters the two ordinary ones, waves the invisible ones through, and U+2028 arrives at the server as three bytes, %E2%80%A8. The same is true of a zero-width space in the middle of a word, which goes out as %E2%80%8B and comes back as U+200B on the other end.

So both of the things people say about forms are wrong at the same time. "The form cleaned it" is wrong: fifty of the 52 arrive byte for byte. "The form handed it straight through" is wrong too: two are deleted, two are rewritten, and a hard-wrapped textarea adds some. The long-running argument about which way round the browsers do it — one camp reporting bare line feeds, another reporting carriage returns — is settled by measuring rather than by picking a side: Chromium 153 sends CRLF. If a backend is seeing bare line feeds, something after the field normalised them. The field did not.

Which means the practical version is short. Read textarea.value when you want to know what the person in front of the form has, and read the request body when you want to know what you are about to store, and do not assume the two agree. If the length matters, count it on the side that will consume it: a maxlength check counts the readable value, while the bytes your database stores are the submitted one, and on a hard-wrapped field those are not the same string.

Getting it back out

Format characters come out with a character class; the fillers have to be named separately because they are letters and no whitespace or format filter will touch them:

text.replace(/[\u00AD\u180E\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF\uFFF9-\uFFFB\u3164\u1160\u115F\uFFA0]/gu, '')

Leave U+200C, U+200D and U+FE0F alone unless the text has no emoji and no Arabic, Persian or Devanagari in it — stripping the joiner is what turns a family emoji into two separate people. If you would rather see each one before deciding, the zero-width space remover marks where they sit in the string, and the invisible characters list covers the rest of the code points by name.

What this page does not tell you

None of this tells you who put the characters there or why, and none of it is evidence that a text came from any particular tool — both families have legitimate uses, and a stray one usually comes from a bad conversion or a copy out of a developer tool. Every figure here was measured in Chromium 153 on Windows; other engines lay out text and segment words with their own rules, and a tokenizer inside a search index is a different thing again from either. Check the numbers on your own stack before you build anything on them.