Zero-Width Space Remover

0 characters
Options

Aggressive mode can visibly change emoji, flags and right-to-left text. Off by default.

Where they are

Every hidden character is marked in place, so you can see it in context before deleting anything.

Nothing scanned yet.

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:

How to spot one

The symptoms are more recognizable than the character:

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:

CharacterUTF-8 bytesPercent-encoded.lengthlen()
U+200BE2 80 8B%E2%80%8B66
U+200CE2 80 8C%E2%80%8C66
U+200DE2 80 8D%E2%80%8D66
U+200EE2 80 8E%E2%80%8E66
U+200FE2 80 8F%E2%80%8F66
U+202AE2 80 AA%E2%80%AA66
U+202CE2 80 AC%E2%80%AC66
U+202DE2 80 AD%E2%80%AD66
U+202EE2 80 AE%E2%80%AE66
U+2060E2 81 A0%E2%81%A066
U+FEFFEF BB BF%EF%BB%BF66
U+00ADC2 AD%C2%AD66
U+034FCD 8F%CD%8F66
U+3164E3 85 A4%E3%85%A466
U+E0001F3 A0 80 81%F3%A0%80%8176
U+1D173F0 9D 85 B3%F0%9D%85%B376

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.

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 declaresText on the pageFile fetchedWhat happened
unicode-range: U+0041one U+200B0 bytesdrawn by the next family in the stack
unicode-range: U+00411,000 U+200B0 bytes1,000 glyphs, all from the fallback
unicode-range: U+200Bone U+200B1,045,720 bytesthe file arrives, the line is 0.00 px wide
no unicode-range at allone U+200B1,045,720 bytesthe 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 forAdvancePainted pixels
Wingdings32.00 px664
Calibri32.44 px655
Courier New38.41 px253
Symbol38.41 px258
Segoe UI41.31 px785
Microsoft YaHei44.63 px864
Arial48.00 px358
Tahoma64.00 px880
MS Gothic64.00 px1,032
SimSun64.00 px0

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.

RecipeWhat it also removesRemoved, 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 together4
replace(/\u200B/g, "")nothing else1
replace(/\p{Cf}/gu, "")U+200E and U+200F, and the soft hyphen inside words that are meant to hyphenate9
replace(/\p{C}/gu, "")the same nine — a wider property, no wider result9
JavaScript trim()nothing, and only at the two ends: U+FEFF, U+00A0, U+2028, U+2029, U+202F5
Python strip()the same four minus U+FEFF, the one character the two languages disagree on4
NFKC normalisationnothing; it swaps three instead — U+00A0 and U+202F become an ordinary space, U+3164 becomes another invisible letter0
keep only ASCIIeverything not Latin: 11 of the 27 code points in a line mixing accents, an emoji and two other scripts15

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:

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.