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).
| Character | Category | Painted ink | Advance width |
|---|---|---|---|
| U+200B ZERO WIDTH SPACE | Cf | 0 px | 0.00 px |
| U+00AD SOFT HYPHEN | Cf | 0 px | 0.00 px |
| U+FEFF ZERO WIDTH NO-BREAK SPACE | Cf | 0 px | 0.00 px |
| U+2060 WORD JOINER | Cf | 0 px | 0.00 px |
| U+200D ZERO WIDTH JOINER | Cf | 0 px | 0.00 px |
| U+180E MONGOLIAN VOWEL SEPARATOR | Cf | 0 px | 0.00 px |
| U+E0020 TAG SPACE | Cf | 0 px | 0.00 px |
| U+3164 HANGUL FILLER | Lo | 0 px | 16.00 px |
| U+1160 HANGUL JUNGSEONG FILLER | Lo | 0 px | 16.00 px |
| U+115F HANGUL CHOSEONG FILLER | Lo | 0 px | 16.00 px |
| U+FFA0 HALFWIDTH HANGUL FILLER | Lo | 0 px | 8.00 px |
| U+2800 BRAILLE PATTERN BLANK | So | 0 px | 10.42 px |
| U+0041 LATIN CAPITAL LETTER A | Lu | 247 px | 11.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 own | Found? |
|---|---|
| U+200B ZERO WIDTH SPACE | no |
| U+00AD SOFT HYPHEN | no |
| U+FEFF ZERO WIDTH NO-BREAK SPACE | no |
| U+2060 WORD JOINER | no |
| U+200D ZERO WIDTH JOINER | no |
| U+180E MONGOLIAN VOWEL SEPARATOR | no |
| U+E0020 TAG SPACE | no |
| U+2028 LINE SEPARATOR | yes |
| U+3164 HANGUL FILLER | yes |
| U+1160 HANGUL JUNGSEONG FILLER | yes |
| U+FFA0 HALFWIDTH HANGUL FILLER | yes |
| U+2800 BRAILLE PATTERN BLANK | yes |
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 inside | Query | Match | Selection returned |
|---|---|---|---|
| U+200B | Hello | yes | 6 code points, invisible one included |
| U+200B | Hell | yes | 5 code points |
| U+200B | Helxo | no | — |
| U+FEFF | Hello | yes | 6 code points, invisible one included |
| U+E0020 | Hello | yes | 6 code points, invisible one included |
| U+2028 | Hello | no | — |
| U+00A0 | Hello | no | — |
| U+0041, a real X | Hello | no | — |
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:
| Character | Line boxes | Height | Overflow width |
|---|---|---|---|
| U+200B | 2 | 40 px | 60 px |
| U+00AD | 3 | 40 px | 63 px |
| U+2028 | 2 | 40 px | 61 px |
| U+FEFF | 1 | 20 px | 106 px |
| U+2060 | 1 | 20 px | 106 px |
| U+200D | 1 | 20 px | 106 px |
| U+180E | 1 | 20 px | 106 px |
| U+E0020 | 1 | 20 px | 106 px |
| U+00A0 | 1 | 20 px | 111 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:
| Separator | Word stops at | Double-click selects |
|---|---|---|
| space U+0020 | 0, 6, 11 | “hello ” |
| U+200B | 0, 5, 11 | “hello” |
| U+2028 | 0, 6, 11 | “hello” plus the separator |
| U+00A0 | 0, 6, 11 | “hello” plus the space |
| U+FEFF | 0, 11 | all 11 code points, one word |
| U+00AD | 0, 11 | all 11 code points, one word |
| U+2060 | 0, 11 | all 11 code points, one word |
| U+200D | 0, 11 | all 11 code points, one word |
| U+E0020 | 0, 12 | all 11 code points, one word |
| U+3164 | 0, 11 | all 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.
Measured 2026-09-24 with Chromium 153.0.8010.12 on Windows, Python 3.13
(unicodedata 15.1.0) and Node 22 via Playwright. Related:
what an invisible character does to a form field,
Unicode tag characters that hide a whole ASCII message,
what invisible characters cost you in tokens,
U+2028 and U+2029 in JSON,
JSON cleaner,
Excel cell cleaner,
Word and Docs cleaner.