Zero-Width Space Remover
Invisible characters pasted out of an AI chat? Use the Claude watermark remover instead — it is tuned for that. This page is for everything else: a JSON payload that will not parse, an Excel column that will not match, a document you are about to submit, or just the list of what is hiding in there. If the text is going into a prompt, see what each one costs in tokens. If it looks empty but is not, check for Unicode tag characters.
Where they are
Every hidden character is marked in place, so you can see it in context before deleting anything.
U+200B always invisible — removed in default mode U+200D invisible only in aggressive mode
What a zero-width space is
U+200B ZERO WIDTH SPACE is a real character that takes up no room. It exists so a browser knows where it may break a long word. It is invisible, it survives copy and paste, and it is the single most common reason a string that looks identical to another one fails to match it.
It is not the only one. The characters below are all invisible and all survive a copy:
U+200Bzero width spaceU+2060word joinerU+FEFFzero width no-break space, better known as a BOMU+00ADsoft hyphenU+034Fcombining grapheme joinerU+E0001language tag andU+E0020–U+E007Ftag characters- Private use characters:
U+E000–U+F8FF, plane 15, plane 16
How to spot one
The symptoms are more recognizable than the character:
- Two strings that render identically compare as unequal
- A database lookup or a
grepmisses a line you can see - JSON or YAML fails to parse with a confusing error near an ordinary character
- A backspace seems to do nothing once
- Code copied from a page or a chat refuses to compile
Paste the text above and this page marks each one where it sits, then removes it.
Which one is actually in your string
Nearly every page on this subject names one character, U+200B, and gives one line
of code to delete it. That pairing is where the hours go, because the character that broke the
string is often not U+200B — and the line of code will not tell you so. It
returns the string unchanged, raises nothing, and reports success.
A document generator produced PDFs with a stray box after one field. The same value rendered
correctly in the browser. The usual fix went in first: strip the UTF-8 bytes
E2 80 8B, E2 80 8C and E2 80 8D, which cover
U+200B, U+200C and U+200D. Nothing changed. Dumping the
bytes of the offending value showed E2 80 AC — U+202C, pop
directional formatting. Removing that one sequence fixed it.
Three characters, three bytes each, one byte apart. The family is wider than the three everyone handles, and every member of it is a candidate for the same symptom. Measured on this machine, with the character appended to a five-letter word:
| Character | UTF-8 bytes | Percent-encoded | .length | len() |
|---|---|---|---|---|
U+200B | E2 80 8B | %E2%80%8B | 6 | 6 |
U+200C | E2 80 8C | %E2%80%8C | 6 | 6 |
U+200D | E2 80 8D | %E2%80%8D | 6 | 6 |
U+200E | E2 80 8E | %E2%80%8E | 6 | 6 |
U+200F | E2 80 8F | %E2%80%8F | 6 | 6 |
U+202A | E2 80 AA | %E2%80%AA | 6 | 6 |
U+202C | E2 80 AC | %E2%80%AC | 6 | 6 |
U+202D | E2 80 AD | %E2%80%AD | 6 | 6 |
U+202E | E2 80 AE | %E2%80%AE | 6 | 6 |
U+2060 | E2 81 A0 | %E2%81%A0 | 6 | 6 |
U+FEFF | EF BB BF | %EF%BB%BF | 6 | 6 |
U+00AD | C2 AD | %C2%AD | 6 | 6 |
U+034F | CD 8F | %CD%8F | 6 | 6 |
U+3164 | E3 85 A4 | %E3%85%A4 | 6 | 6 |
U+E0001 | F3 A0 80 81 | %F3%A0%80%81 | 7 | 6 |
U+1D173 | F0 9D 85 B3 | %F0%9D%85%B3 | 7 | 6 |
Every row adds one to len(), and fourteen of the sixteen add one to
.length. The two that add two sit outside the basic plane, so JavaScript counts a
surrogate pair and reports a gap twice the size of the number of characters present. A length
check is therefore a count of code units, not of characters, and the two agree only for the
characters in the first three quarters of the range.
The byte column is what makes the readout usable. Twelve of these are three bytes,
U+00AD and U+034F are two, and the two astral characters are four
— which is why the percent-encoded form is always nine, six or twelve characters long and
never anything in between.
The length is the tell
A column in a production table held values that looked like five characters. The length function returned 44. Five visible plus thirty-nine invisible is 44, and thirty-nine characters at three bytes each puts the value at 122 bytes — which is what a byte count on the same value reported.
That arithmetic is the whole diagnostic. You do not need to know which character is in there to
know how many are: the gap between the length you can see and the length the machine reports is
the count. Four characters of gap means four invisible characters, whether they are
U+200B, U+202C, or four different ones in a row.
Two functions people reach for first do not close that gap. TRIM() handles spaces.
CLEAN() removes codes 0 to 31 — Microsoft's own documentation says so, and
adds that the further nonprinting values it lists are not removed by it either. 8203, the
decimal form of U+200B, is in neither set. A cell cleaned with both still fails to
match, and both functions returned without an error.
Where the number shows up unasked
Four places hand you a readout whether you wanted one or not. Each is a case where the string looks correct and the machine disagrees.
- A URL. All sixteen characters above are escaped by
encodeURI, so a link that looks clean carries%E2%80%8Bin the path once the address bar normalises it. A request that 404s against a path you can read is this, and the percent-encoded form is the readout. - A table name. A table created with one of these characters in its name is listed by the command that lists tables, and refused by the query that selects from it, with the server reporting that the table does not exist. Two commands, one name, opposite answers, neither of them a bug.
- A generated PDF. A renderer with a font that draws a replacement glyph paints a box where the page painted nothing. Same string, two renderers, two answers — and only one of them tells you a character is there.
- A spreadsheet cell.
LEN()counts it, and the two cleanup functions above pass it through. The cell is longer than it looks and still will notVLOOKUP.
The order matters more than the tool. Identify first, then delete: a deletion aimed at the wrong code point returns success, changes nothing, and is indistinguishable from a fix. That is the position this page takes, and it is the opposite of the one most pages take, which is to hand over the snippet and let the snippet be the diagnosis. The recipe table below tells you what each snippet takes out — once you know what you are aiming at.
Your font decides whether it stays invisible
Nothing about U+200B makes it invisible. It is invisible because the font that ends
up serving it has a glyph for it that advances zero and paints nothing. Change which font serves
it and the answer can change — and on a page loading a web font, that choice is made before
the character is drawn at all, by one line of CSS.
A subsetted web font declares which code points it is willing to serve with
unicode-range. Four pages were rendered in the same browser with the same real font
file behind them, differing only in that one declaration:
The @font-face declares | Text on the page | File fetched | What happened |
|---|---|---|---|
unicode-range: U+0041 | one U+200B | 0 bytes | drawn by the next family in the stack |
unicode-range: U+0041 | 1,000 U+200B | 0 bytes | 1,000 glyphs, all from the fallback |
unicode-range: U+200B | one U+200B | 1,045,720 bytes | the file arrives, the line is 0.00 px wide |
no unicode-range at all | one U+200B | 1,045,720 bytes | the file arrives, the line is 0.00 px wide |
A thousand invisible characters and not one byte of the font; a single invisible character inside the declared range and the whole file comes down. The range is matched on the code point alone. It does not ask whether the character has a visible glyph, and it does not ask how many of them the page is carrying.
Leaving it out breaks nothing. Developer tools report which family actually drew each run of text, and in the first two rows above that family is the second name in the stack, not the web font. The character arrives intact either way — it is simply being served by a font the page never chose for it.
That is worth knowing because the obvious way to check gives the wrong answer.
document.fonts.check("40px Face", "\u200B") returns true whether
U+200B is inside the range or outside it: across the four rows above it was
true for a character no face had been asked to cover, and false for one
that had, at the moment the file was still arriving. The method reports the load state of the
faces that match. A character no face matches needs nothing loaded, so it passes.
What the family in your CSS is actually worth
On the machine these numbers come from the agreement is absolute. Twenty-two families named one
at a time, at sizes from 8 px to 96 px: U+200B advanced 0.00 px and painted nothing
in every one of them. In twenty-one of the twenty-two the family reported as having drawn it was
the family that was named; the exception was a Japanese family that is not installed here, where
the browser substituted a different one without saying so.
Substitution is the ordinary case for other members of the same set. Ask any of those twenty-two
families for U+3164, the Hangul filler, and all twenty-two are answered by the same
Korean font, which gives it a full em — 64 px at 64 px, 16 px at 16 px — while
painting nothing. Ask for U+070F and all twenty-two are answered by a historic-script
font, whatever you named. The family in your stylesheet is a request, not a guarantee.
When nothing on the device has the code point at all, what gets drawn is the font’s own replacement glyph, and that is not a standard shape either. Measured at 64 px against a code point no font defines:
| Family asked for | Advance | Painted pixels |
|---|---|---|
| Wingdings | 32.00 px | 664 |
| Calibri | 32.44 px | 655 |
| Courier New | 38.41 px | 253 |
| Symbol | 38.41 px | 258 |
| Segoe UI | 41.31 px | 785 |
| Microsoft YaHei | 44.63 px | 864 |
| Arial | 48.00 px | 358 |
| Tahoma | 64.00 px | 880 |
| MS Gothic | 64.00 px | 1,032 |
| SimSun | 64.00 px | 0 |
So the box people report seeing is not one thing. It runs from half an em to a full em, and in one family here it reserves a whole character cell and paints nothing at all — a gap the width of a character with no mark in it. A device whose fonts differ from the ones measured here can substitute a drawn glyph for a character that is invisible on this one, and the character itself never changed.
Measured in this tab on a canvas at 64 px: how far the caret moves, and how many pixels get painted. The last row is the control — a code point no font defines, so what it shows is that family’s replacement glyph. Which family really drew each one is something the browser only reports to developer tools.
If you ship a subsetted font, the decision is worth making on purpose rather than inheriting.
Left out, U+200B costs nothing to render and is handed to whatever family comes next,
which for a zero-advance character changes nothing you can see. Included, every page that carries
one downloads a file for a glyph that is never painted. And none of this touches the copy: the
character rides along in the clipboard whichever family drew it.
Which removal recipe actually removes it
Line up the fifteen invisible characters a piece of text is most likely to be carrying and run the usual cleanup snippets over them one at a time: they do not remove the same set. The most widely copied JavaScript recipe for this job takes four of the fifteen, and two of those four are the characters holding Persian words and emoji families together.
| Recipe | What it also removes | Removed, of 15 |
|---|---|---|
replace(/[\u200B-\u200D\uFEFF]/g, "") | U+200C and U+200D — the non-joiner inside Persian and Devanagari words, and the joiner holding an emoji family together | 4 |
replace(/\u200B/g, "") | nothing else | 1 |
replace(/\p{Cf}/gu, "") | U+200E and U+200F, and the soft hyphen inside words that are meant to hyphenate | 9 |
replace(/\p{C}/gu, "") | the same nine — a wider property, no wider result | 9 |
JavaScript trim() | nothing, and only at the two ends: U+FEFF, U+00A0, U+2028, U+2029, U+202F | 5 |
Python strip() | the same four minus U+FEFF, the one character the two languages disagree on | 4 |
| NFKC normalisation | nothing; it swaps three instead — U+00A0 and U+202F become an ordinary space, U+3164 becomes another invisible letter | 0 |
| keep only ASCII | everything not Latin: 11 of the 27 code points in a line mixing accents, an emoji and two other scripts | 15 |
Two of the eleven survivors are worth knowing by name. The word joiner U+2060 is the
character most blank-text tools hand out, because unlike U+200B it never offers the
browser a place to break a line — and it walks straight through the four-character recipe,
whose range stops at U+200D. The combining grapheme joiner U+034F survives
even \p{C}, because Unicode files it under Mn, a combining mark, and not
under C at all: the property that reads as “every invisible character” is
not that property.
The damage from the four-character recipe is not visible in the result. Run it over a family emoji — one grapheme, seven code points — and you get four code points back: the three joiners are gone and what was one picture is now four separate people in a row. The same eight-code-point Persian word comes back as seven, the same five-code-point Devanagari word as four. Both still render. Neither is the string you started with.
The shell has the same split, only there it is silent. sed 's/\u200B//g' matches
nothing and exits 0; so does sed 's/\x{200b}//g'. The forms that work are
the one written in bytes, sed 's/\xe2\x80\x8b//g', and a literal character handed over
by the shell, tr -d $'\u200B'. A cleanup pass built on the escape form reports success
and changes nothing.
One disagreement sits between two languages usually assumed to agree. JavaScript’s
trim() deletes U+FEFF and Python’s strip() does not;
the pattern \s matches U+FEFF in JavaScript and not in Python. Both agree
on U+00A0, U+2028, U+2029 and U+202F. So a field
holding five U+FEFF characters is empty to a browser and five characters long to the
Python script that reads it.
That is the line the two modes above are drawing. Default takes out the eight characters that are
never load-bearing — U+00AD, U+034F, U+200B,
U+2060, U+FEFF and the three interlinear annotation characters —
including the two that both popular recipes leave behind. Aggressive adds the joiners, the direction
marks and the bidi isolates, and the warning under it is there because that is the point where
cleanup starts changing what the text says. For the full roster rather than the removal,
see the list of what is hiding in there.
What this keeps by default
Some invisible characters carry meaning, so they stay unless you turn on aggressive mode:
U+200Dzero width joiner — holds emoji families togetherU+200Czero width non-joiner — needed for Persian, Arabic and DevanagariU+FE0Fvariation selector 16 — keeps emoji rendering as emoji- Regional indicator pairs (flags) and bidi marks in matched pairs
Is anything uploaded?
No. There is no backend behind this page. The scan and the cleanup both run in this browser tab, and the text never leaves your device.