Layerbit
Runs entirely in your browser

Unicode Inspector: Find Invisible Characters

See exactly what is in a string: every code point with its UTF-8 and UTF-16 bytes, zero-width and invisible characters made visible, normalization forms compared, mixed-script lookalikes flagged, and escapes for JavaScript, Python, CSS and HTML.

Text
Findings
Findings about invisible characters, mixed scripts and normalization appear here.
Code points
#CharCode pointCategoryScriptUTF-8UTF-16Escape

Why text that looks the same is not the same

A string is a sequence of code points, and a great deal of what can go wrong with text is invisible on screen. Two strings can render identically and still fail an equality check, a database lookup, a filename match or a password comparison. This tool exists to make the difference visible: it lists every code point, shows the bytes each one becomes, and highlights the specific characters that cause trouble.

Zero-width and format characters

U+200B ZERO WIDTH SPACE, U+200C/D joiners, U+2060 WORD JOINER, U+FEFF (the byte-order mark, which also appears mid-string when files are concatenated) and the bidirectional controls U+202A–U+202E and U+2066–U+2069 all render as nothing. They arrive via copy-paste from PDFs, web pages and chat apps, and they break string comparison, regexes, CSV parsing and JSON keys. The findings panel counts them, the table marks them, and the clean-copy button strips them.

Lookalikes across scripts

Cyrillic а (U+0430), Greek ο (U+03BF) and Latin a and o are visually identical in most fonts. A username, domain or package name mixing scripts inside one word is the classic homograph attack — раypal with two Cyrillic letters. The tool detects a word containing letters from more than one script and names the scripts, which is the same check IDNA-aware browsers apply to domain names.

Normalization

é can be one code point (U+00E9) or two (U+0065 U+0301, e plus combining acute). Both display the same; they are different strings. NFC composes, NFD decomposes, and the K forms additionally fold compatibility characters (a ligature fi to fi, a full-width A to A). The table shows the length of your text in each form and whether it is already normalized, and which form to store is discussed below.

Bytes, units and graphemes

"Length" has four answers. A single flag emoji 🇦🇪 is one grapheme (what a user sees), two code points (two regional indicators), four UTF-16 units (what JavaScript's .length counts) and eight UTF-8 bytes (what a database column stores). A VARCHAR(10) that is really 10 bytes holds one such flag and nothing else. The statistics strip shows all four counts at once.

Which normalization form to store

Normalize on input, once, and store NFC. It is the form most text arrives in, the form the W3C recommends for the web, and the form that keeps é as a single code point so length checks and index prefixes behave. Use NFKC only for identifiers that must be compared loosely — usernames, search keys — because it is lossy: it folds superscripts to digits and removes distinctions that are sometimes meaningful (² becomes 2). Never compare strings for equality without agreeing on a form; PostgreSQL, MySQL and most languages provide a normalize() function, and JavaScript has String.prototype.normalize(), which is what this page uses.

macOS is the notable exception on the file system: HFS+ stored filenames in a variant of NFD, so a file created on a Mac and listed from Linux could have a decomposed name that does not match the composed name in your database. APFS preserves what it is given but compares normalization-insensitively. If a filename check works on one machine and fails on another, paste the name here and compare the forms.

Characters worth refusing at the door

Code pointNameWhy it matters
U+200BZero width spaceInvisible; splits words for search and breaks exact matching. Very common in copied text.
U+FEFFByte order markLegitimate as the first character of a file; anywhere else it is garbage that breaks JSON, YAML and shebang lines.
U+00A0No-break spaceLooks like a space, is not one to split(' ') or trim() in some languages. Word processors insert it freely.
U+202ERight-to-left overrideReverses display order of what follows: invoice‮fdp.exe shows as invoiceexe.pdf. Used in file-name and source-code attacks (the "Trojan Source" class).
U+2028 / U+2029Line / paragraph separatorValid in JSON, historically illegal inside a JavaScript string literal, so embedding JSON in a script tag could throw.
U+0000NullTerminates strings in C; many databases and file systems reject it. Its presence usually means binary data was read as text.
U+FFFDReplacement characterNot a problem in itself: it is the evidence of one. It appears where a decoder met bytes that were not valid in the encoding it assumed, usually Latin-1 read as UTF-8 or the reverse.

How the inspection works

The string is walked by code point (for…of, not by UTF-16 index), each code point is classified with Unicode property escapes in regular expressions — \p{L}, \p{Script=Cyrillic}, \p{Cf} and so on — and graphemes come from Intl.Segmenter, the browser's implementation of the Unicode grapheme cluster rules. UTF-8 bytes are produced by TextEncoder. Normalization uses String.prototype.normalize(). Everything runs locally, and the character names shown for the flagged code points come from a small table in the page rather than the full Unicode name database, which is why only the notable ones are named.

Frequently asked questions

One of three things is almost always the cause: an invisible character such as a zero-width space or a byte-order mark in one string; a different normalization form, where an accented letter is one code point in one string and a base letter plus combining mark in the other; or a lookalike from another script, such as a Cyrillic letter standing in for a Latin one. Paste both strings here in turn and compare the code point lists; the findings panel names the first two cases directly and the mixed-script check catches the third.

U+200B is a Unicode format character that takes no space on screen and exists to mark a possible line break inside a word. It enters text through copy and paste from PDFs, web pages and messaging apps. It breaks exact matching, search and parsing because it is a real character in the string. Remove it by replacing the code point: in JavaScript str.replace(/​/g, ''), or more broadly str.replace(/\p{Cf}/gu, '') to strip every format character. The clean-copy button on this page does the same and also converts no-break spaces to ordinary spaces.

Because .length counts UTF-16 code units, and any code point above U+FFFF, which includes almost all emoji, is stored as a surrogate pair of two units. A family emoji built from several people joined with zero-width joiners can be eleven code points and over twenty units while displaying as one glyph. To count what a user sees, use Intl.Segmenter with granularity grapheme; to count code points, spread the string into an array or iterate with for…of.

NFC, in almost every case. It is the composed form: an accented letter is a single code point where one exists. It is what most input methods and browsers produce, it is the W3C's recommendation for the web, and it keeps lengths and prefixes stable. Normalize once when data enters your system and store the result. Use NFKC only for loose identifier matching, since it discards distinctions like superscripts and ligatures that can be meaningful in content.

Each word is examined for which scripts its letters belong to, using Unicode script properties. A word whose letters come from more than one script — Latin and Cyrillic, or Latin and Greek — is flagged with the scripts named. Characters that are common to all scripts, such as digits and punctuation, are ignored, and Japanese text mixing Han, Hiragana and Katakana is treated as one writing system since that mixture is normal. This is a lookalike heuristic similar to the checks browsers apply to internationalized domain names, not a security decision on its own.

No. Classification uses the Unicode data built into your browser's JavaScript engine, through regular-expression property escapes, Intl.Segmenter and String.prototype.normalize. There is no server request, which you can confirm in the Network tab of your browser's developer tools or by using the page offline. The text people inspect is often a password, a token or a customer's name, so this is not a tool that should ever upload its input.

Changelog