Tldr

Invisible Unicode characters fall into five groups: zero-width and joiners, fillers and blanks, bidirectional controls, invisible math operators, and selectors plus tags. Most are Default_Ignorable_Code_Point, but not all (U+2800 BRAILLE PATTERN BLANK renders empty and is not), and category Cf is not the same set either. The tables below give code point, name, category and status; the snippets produce each character without pasting it, and a short detector finds them.

Pasting the characters themselves into a page defeats the purpose, since you cannot see them and a CMS may eat them. So this reference gives code points, and the sections after the tables show how to produce any of them in JavaScript or Python. The techniques that abuse them are indexed in the Unicode steganography techniques and detection hub.

Categories come from Python 3.14’s unicodedata (Unicode 16.0.0 data). The Default_Ignorable column is checked against DerivedCoreProperties-18.0.0.txt: “yes” means the code point is in the Default_Ignorable_Code_Point range list there, which is what a renderer uses to decide whether to draw anything for a character it has no glyph for.

Zero-width characters and joiners

Code pointNameCategoryUsed forDefault_Ignorable
U+200BZERO WIDTH SPACECfallows a line break, shows nothingyes
U+200CZERO WIDTH NON-JOINERCfprevents joining of neighbouring lettersyes
U+200DZERO WIDTH JOINERCfforces joining; glues emoji into sequencesyes
U+2060WORD JOINERCfforbids a line break at this pointyes
U+FEFFZERO WIDTH NO-BREAK SPACECfbyte order mark at the start of a fileyes
U+00ADSOFT HYPHENCfvisible only where a line breaksyes
U+034FCOMBINING GRAPHEME JOINERMncombining mark with no visible formyes

These seven carry the zero-width channel described in zero-width steganography. The bit-carrying pair is U+200B and U+200C; the others are used as extra symbols.

Fillers and blanks that render empty

Code pointNameCategoryUsed forDefault_Ignorable
U+115FHANGUL CHOSEONG FILLERLoplaceholder in Hangul syllable compositionyes
U+1160HANGUL JUNGSEONG FILLERLoplaceholder in Hangul syllable compositionyes
U+3164HANGUL FILLERLofull width filler, renders blankyes
U+FFA0HALFWIDTH HANGUL FILLERLohalf width filler, renders blankyes
U+2800BRAILLE PATTERN BLANKSoBraille cell with no dots, renders blankno
U+17B4KHMER VOWEL INHERENT AQMninherent vowel with no visible formyes
U+180EMONGOLIAN VOWEL SEPARATORCfMongolian vowel separatoryes

The surprises sit here. U+3164 and U+FFA0 are category Lo, letters, so \p{L} matches them and Python’s 'a\u3164b'.isidentifier() is True; they are also Default_Ignorable, which is why the broader filter in the removal how-to catches them and a Cf-only filter does not. U+2800 is the opposite: it renders as a blank cell, is category So, is not Default_Ignorable, and does not match \s, so "\u2800".trim() leaves it in place and every strip-the-invisibles filter misses it.

Bidirectional controls

Code pointNameCategoryUsed forDefault_Ignorable
U+200ELEFT-TO-RIGHT MARKCfdirection markyes
U+200FRIGHT-TO-LEFT MARKCfdirection markyes
U+061CARABIC LETTER MARKCfdirection mark for Arabic textyes
U+202ALEFT-TO-RIGHT EMBEDDINGCfexplicit embeddingyes
U+202BRIGHT-TO-LEFT EMBEDDINGCfexplicit embeddingyes
U+202CPOP DIRECTIONAL FORMATTINGCfends an embedding or overrideyes
U+202DLEFT-TO-RIGHT OVERRIDECfexplicit overrideyes
U+202ERIGHT-TO-LEFT OVERRIDECfexplicit override; used in file name spoofingyes
U+2066LEFT-TO-RIGHT ISOLATECfexplicit isolateyes
U+2067RIGHT-TO-LEFT ISOLATECfexplicit isolateyes
U+2068FIRST STRONG ISOLATECfisolate, direction taken from contentyes
U+2069POP DIRECTIONAL ISOLATECfends an isolateyes

UAX #9 defines the nine explicit formatting characters (U+202A to U+202E and U+2066 to U+2069) and the three marks (U+200E, U+200F, U+061C). They change how text is laid out without adding a glyph, which is the basis of the reordering attacks in the right-to-left override (U+202E) note.

Invisible math operators

Code pointNameCategoryUsed forDefault_Ignorable
U+2061FUNCTION APPLICATIONCfmath markup, no glyphyes
U+2062INVISIBLE TIMESCfmath markup, no glyphyes
U+2063INVISIBLE SEPARATORCfmath markup, no glyphyes
U+2064INVISIBLE PLUSCfmath markup, no glyphyes

Selectors and tags

Code pointNameCategoryUsed forDefault_Ignorable
U+180BMONGOLIAN FREE VARIATION SELECTOR ONEMnglyph variant of the preceding Mongolian letteryes
U+FE00VARIATION SELECTOR-1Mnglyph variant of the preceding characteryes
U+FE0EVARIATION SELECTOR-15Mnrequests text presentationyes
U+FE0FVARIATION SELECTOR-16Mnrequests emoji presentationyes
U+E0100VARIATION SELECTOR-17Mnfirst of 240 ideographic selectorsyes
U+E0001LANGUAGE TAGCfdeprecatedyes
U+E0020TAG SPACECffirst of the tag characters that mirror ASCIIyes
U+E0041TAG LATIN CAPITAL LETTER ACftag twin of ASCII Ayes
U+E007FCANCEL TAGCfends an emoji tag sequenceyes

All 256 variation selectors and the whole Tags block share these rows’ status. Their abuse is covered in variation selectors and ASCII smuggling.

The Default_Ignorable range list is wider than the tables. It also covers U+17B5, U+180C to U+180D, U+180F, U+2065, U+206A to U+206F, U+FFF0 to U+FFF8, U+1BCA0 to U+1BCA3, U+1D173 to U+1D17A and several reserved, unassigned ranges inside the Tags block area (U+E0000, U+E0002 to U+E001F, U+E0080 to U+E00FF, U+E01F0 to U+E0FFF), 4174 code points in total.

Category Cf is not the same set

A shortcut that comes up often is “invisible means Cf”. Counting in Node 24 (Unicode 17.0 data), \p{Cf} matches 170 code points and \p{Default_Ignorable_Code_Point} matches 4174. Only 138 code points are in both. The rest of the Cf characters, such as U+0600 ARABIC NUMBER SIGN, are format characters that are meant to be visible, which is what the Prepended_Concatenation_Mark exception in the derivation is for. And most of the DICP set is not Cf at all: the Hangul fillers are Lo, the combining grapheme joiner and every variation selector are Mn, and the reserved ranges are unassigned (Cn).

Produce any of them without pasting

JavaScript: String.fromCodePoint or a \u{...} escape.

const INVISIBLES = {
  ZWSP: 0x200b, ZWNJ: 0x200c, ZWJ: 0x200d, WJ: 0x2060, BOM: 0xfeff,
  SHY: 0x00ad, CGJ: 0x034f, HANGUL_CHOSEONG_FILLER: 0x115f, HANGUL_JUNGSEONG_FILLER: 0x1160,
  HANGUL_FILLER: 0x3164, HALFWIDTH_HANGUL_FILLER: 0xffa0, BRAILLE_BLANK: 0x2800,
  MONGOLIAN_VOWEL_SEPARATOR: 0x180e, LRM: 0x200e, RLM: 0x200f, ALM: 0x061c,
  LRE: 0x202a, RLE: 0x202b, PDF: 0x202c, LRO: 0x202d, RLO: 0x202e,
  LRI: 0x2066, RLI: 0x2067, FSI: 0x2068, PDI: 0x2069,
  VS1: 0xfe00, VS15: 0xfe0e, VS16: 0xfe0f, VS17: 0xe0100, TAG_A: 0xe0041, CANCEL_TAG: 0xe007f,
};
const make = (name) => String.fromCodePoint(INVISIBLES[name]);
console.log(make("ZWSP").length, make("TAG_A").length, `a${make("ZWSP")}b`.length); // 1 2 3

Python: chr(0x200B) or a named escape, which reads better in review.

print(ascii("\N{ZERO WIDTH SPACE}\N{BRAILLE PATTERN BLANK}\N{HANGUL FILLER}\N{TAG LATIN CAPITAL LETTER A}"))
# '\u200B\u2800\u3164\U000e0041'

A one-screen detector

The JavaScript version flags Default_Ignorable characters, format characters, every space separator except U+0020, line and paragraph separators, and U+2800, with line and column. The Python version prints names as well.

const SUSPECT = /[[\p{Default_Ignorable_Code_Point}\p{Cf}\p{Zs}\p{Zl}\p{Zp}\u2800]--[ ]]/gv;
 
function scan(text) {
  const hits = [];
  text.split("\n").forEach((line, i) => {
    let col = 0;
    for (const ch of line) {
      col++;
      if (ch.match(SUSPECT)) hits.push(`${i + 1}:${col} U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`);
    }
  });
  return hits;
}
 
const sample = `ok\nstop${make("ZWSP")}ped\n${make("BRAILLE_BLANK")}x\tnbsp\u00A0here ${make("HANGUL_FILLER")}`;
console.log(scan(sample)); // [ '2:5 U+200B', '3:1 U+2800', '3:8 U+00A0', '3:14 U+3164' ]
import unicodedata
 
EXTRA = {0x2800, 0x3164, 0xFFA0, 0x115F, 0x1160, 0x034F}
 
def suspect(c):
    cp, cat = ord(c), unicodedata.category(c)
    return (
        cat in {"Cf", "Zl", "Zp"}
        or (cat == "Zs" and c != " ")
        or cp in EXTRA
        or 0xFE00 <= cp <= 0xFE0F
        or 0xE0100 <= cp <= 0xE01EF
    )
 
def scan(text):
    for n, line in enumerate(text.splitlines(), 1):
        for col, c in enumerate(line, 1):
            if suspect(c):
                yield f"{n}:{col} U+{ord(c):04X} {unicodedata.name(c, '<unnamed>')}"
 
sample = "ok\nstop\u200Bped\n\u2800x\tnbsp\u00A0here \u3164\n\U000e0041"
print(*scan(sample), sep="\n")
# 2:5 U+200B ZERO WIDTH SPACE
# 3:1 U+2800 BRAILLE PATTERN BLANK
# 3:8 U+00A0 NO-BREAK SPACE
# 3:14 U+3164 HANGUL FILLER
# 4:1 U+E0041 TAG LATIN CAPITAL LETTER A

U+00A0 shows up because it is a space separator; that is noise in prose and a lead in source code. Legitimate ZWJ in emoji and ZWNJ in Persian will trigger as well, so a hit is a place to look, not a verdict. Removal rules with the emoji exceptions are in the guide to removing zero-width characters.

Sources

  1. Unicode DerivedCoreProperties-18.0.0.txt: the Default_Ignorable_Code_Point derivation, ranges and the total of 4174.
  2. UAX #9, Unicode Bidirectional Algorithm: the explicit formatting characters and implicit marks.
  3. UTS #51, Unicode Emoji: U+FE0E and U+FE0F, emoji tag sequences, ZWJ sequences.
  4. Unicode chart: Tags, U+E0000 to U+E007F: the Tags block and the deprecation of U+E0001.
  5. Unicode PropList.txt: the Variation_Selector property.