Invisible character: what it is, and what it does to your forms
Short answer: an invisible character is a Unicode format
character — general category Cf — that holds a position in your string
and draws nothing on screen. Unicode 15.1 assigns 170 of them. Measured in
Chromium 153 at 16px system-ui, 128 render at exactly 0.00 px, 8 more measure
under 0.35 px, and 34 draw a real glyph between 9.41 px and 24.81 px wide. The one you almost
certainly have is U+200B ZERO WIDTH SPACE: 3 bytes, 1 character, invisible,
survives copy and paste. On a form, measured: Chromium rejects every one of the 128
inside an email field, while a field holding only one still counts as filled
for required, and five of them are enough to fill a maxlength="5"
input that looks completely empty.
What counts as an invisible character
Unicode puts these in general category Cf, "format". They are real characters with
real code points; they just have no glyph of their own. A string containing one has a different
length, different bytes and a different identity from the same string without it, while looking
identical in every UI you own.
Enumerated with Python 3.13 (unicodedata, Unicode 15.1.0): 170 Cf
characters in 21 ranges, next to 65 control characters (Cc), 17 spaces
(Zs) and one each of line and paragraph separator. Then each of the 170 was
measured in Chromium 153 by rendering |X| and subtracting the width of
||:
| Codepoint range | Count | Measured advance width |
|---|---|---|
| U+200B–U+200F | 5 | 0.00 px (U+200C, U+200F: 0.02 px) |
| U+202A–U+202E | 5 | 0.00 px (U+202B, U+202E: 0.02 px) |
| U+2060–U+2064 | 5 | 0.00 px |
| U+2066–U+206F | 10 | 0.00 px (U+2067: 0.02 px) |
| U+E0020–U+E007F | 96 | 0.00 px |
| U+1D173–U+1D17A | 8 | 0.00 px |
| U+E0001 | 1 | 0.00 px |
| U+FEFF | 1 | 0.00 px |
| U+00AD | 1 | 0.00 px |
| U+180E | 1 | 0.00 px |
| U+061C | 1 | 0.02 px — sub-pixel, still nothing to see |
| U+070F | 1 | 0.34 px — sub-pixel |
| U+0600–U+0605 | 6 | visible, 9.41–24.81 px (Arabic number and verse marks) |
| U+06DD, U+0890–U+0891, U+08E2 | 4 | visible, 11.17–17.98 px |
| U+FFF9–U+FFFB | 3 | visible, 11.16 px (interlinear annotation) |
| U+110BD, U+110CD | 2 | visible, 10.41 px (Kaithi number signs) |
| U+13430–U+1343F | 16 | visible, 9.72–11.16 px (Egyptian hieroglyph format) |
| U+1BCA0–U+1BCA3 | 4 | visible, 18.06 px (shorthand format) |
For scale, a letter A measures 11.27 px in the same run, so the 34 visible ones are
not marginal — a few are wider than two letters. Being in category Cf does
not guarantee you cannot see it.
Eight of them, measured end to end
Node 22.22.2 and Python 3.13.14 for the language behaviour, Chromium 153.0.8010.12 for the
rendering and the form checks. Advance width is the |X| minus ||
measurement above.
| Codepoint | Name | Width | JS trim() | JS /\s/ | NFKC | UTF-8 | In a URL |
|---|---|---|---|---|---|---|---|
| U+200B | ZERO WIDTH SPACE | 0.00 px | kept | no | kept | 3 B | %E2%80%8B |
| U+200C | ZERO WIDTH NON-JOINER | 0.02 px | kept | no | kept | 3 B | %E2%80%8C |
| U+200D | ZERO WIDTH JOINER | 0.00 px | kept | no | kept | 3 B | %E2%80%8D |
| U+2060 | WORD JOINER | 0.00 px | kept | no | kept | 3 B | %E2%81%A0 |
| U+FEFF | ZERO WIDTH NO-BREAK SPACE | 0.00 px | removed | yes | kept | 3 B | %EF%BB%BF |
| U+00AD | SOFT HYPHEN | 0.00 px | kept | no | kept | 2 B | %C2%AD |
| U+180E | MONGOLIAN VOWEL SEPARATOR | 0.00 px | kept | no | kept | 3 B | %E1%A0%8E |
| U+2062 | INVISIBLE TIMES | 0.00 px | kept | no | kept | 3 B | %E2%81%A2 |
We expected none of them to be whitespace. That was wrong for one. U+FEFF is
the only one of the eight that JavaScript treats as whitespace: /\s/.test("\uFEFF")
is true, and trim() strips it off both ends. The other seven survive
trim(), so "ada@example.com\u200B".trim() is still 16 characters long
and still carries the character. Python disagrees: str.strip() removes
none of the eight, and re.search(r"\s", "\uFEFF") finds nothing. If your
pipeline trims input in Python and validates it in a browser, the same string can be clean on
one side and dirty on the other.
Measured: what happens on a real form
Each row was run in Chromium 153 against a real input element, not reasoned about.
| Check | Result |
|---|---|
<input type="email"> with the character in the local part |
Rejected, 128 out of 128. Zero of the zero-width format characters are accepted. Chromium's message names the character: "A part followed by '@' should not contain the symbol ' '." |
Regex /^[^\s@]+@[^\s@]+\.[^\s@]+$/ |
Accepts U+200B, U+200C, U+200D, U+2060, U+00AD, U+180E and U+2062 in the local part. Rejects U+FEFF — the only one \s matches. |
Regex /^\S+@\S+\.\S+$/ |
Same split: seven pass, U+FEFF fails. |
Regex /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/ |
Rejects all eight, and rejects any non-ASCII character at all — including ones you want, like a non-Latin-script address. |
required text input holding only that character |
Passes as filled, 128 out of 128. An empty string is the only value that fails the check. |
maxlength="5", typing the character 12 times |
Five BMP invisible characters fit (5 UTF-16 units). Only two astral ones fit — U+E0020 and U+1D173 cost 2 units each, so the field stops at 4 units and looks empty. |
| Equality and search | "hello" === "he\u200Bllo" is false, indexOf("hello") returns -1, but localeCompare returns 0 — collation calls them equal, code does not. |
| Serialization | JSON.stringify emits U+200B raw into the output. Python json.dumps escapes it as \u200b. Both parse back to the same 3-character string. |
| Size on the wire | 3 bytes in UTF-8, 9 characters once percent-encoded (%E2%80%8B). A string that looks 15 characters long can be 17 bytes in a POST body. |
Why the browser catches it and your regex does not
The HTML specification defines what an email address may contain, and Chromium implements that
list literally: an unlisted character in the local part is an error, invisible or not. A
hand-written pattern like [^\s@]+ means "anything that is neither whitespace nor an
at sign" — and U+200B is neither, so it sails through. The two disagree in the opposite
direction as well: required only asks whether the value is the empty string, and a
single invisible character is not empty.
None of this is a bug in either place. It means "the form accepted it" and "the string is clean" are two different statements, and a field can be simultaneously valid and wrong.
How to find one
Paste into the box at the top of this page, or run one of these. Both were run on
Hello<U+200B>World<U+FEFF>! and both report two hits.
// JavaScript
const RE = /[\u00AD\u180E\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF\uFFF9-\uFFFB]/gu;
const hits = text.match(RE) || []; // -> ['\u200B', '\uFEFF']
const clean = text.replace(RE, ''); // -> 'HelloWorld!'
# Python
hits = [ch for ch in text if not ch.isprintable()] # -> ['\u200b', '\ufeff']
clean = ''.join(ch for ch in text if ch.isprintable())
Python's isprintable() returned False for all eight characters tested
and True for ordinary letters, which makes it a one-line detector with no table to
maintain. In a database, the tell is length: LENGTH() in MySQL counts bytes and
CHAR_LENGTH() counts characters, so a 3-byte invisible character shows up as a
difference between the two.
How to remove one, and what not to remove
Deleting by codepoint range is the safe move, and the ranges above are measured. Three of them carry meaning that you can see:
- U+200D ZERO WIDTH JOINER holds emoji sequences together as one picture. Remove it and a family emoji becomes four separate faces.
- U+200C ZERO WIDTH NON-JOINER is required for correct Persian, Arabic and Devanagari.
- U+FE0F VARIATION SELECTOR-16 is what keeps an emoji rendering as a colour emoji.
Everything in the zero-width table above is otherwise safe to delete from ordinary text. If you would rather not decide, the Claude watermark remover keeps the meaningful ones by default and only takes the rest out when you turn on aggressive mode; the zero-width space remover shows you where each one sits before anything is deleted.
What this does not tell you
These measurements describe how invisible characters behave. They say nothing about who put one there or why, and finding one is not evidence that a text was generated by a model — rich text editors, document exports and web pages all produce them too. The width and validation numbers are from one browser build and one font stack; a different font can move the sub-pixel rows, though not the 0.00 px ones. Every count here came from Unicode 15.1 and Chromium 153, so re-run the snippets on your own stack before you rely on a specific number.
Measured 2026-09-23 with Python 3.13.14 (unicodedata 15.1.0), Node 22.22.2 and Chromium 153.0.8010.12. Tools: Claude watermark remover, zero-width space remover, JSON cleaner, Excel cell cleaner. Full codepoint reference: invisible Unicode characters. Feeding the text to a model? What hidden characters cost you in tokens. Building JSON in JavaScript? U+2028 and U+2029. Text that looks empty but is not? Unicode tag characters hide a whole ASCII message with nothing on screen.