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.

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.

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.

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.