🔤

NFKC strips every fancy Unicode style except the one people actually use


If you have ever written input validation for usernames, you have probably reached for String.prototype.normalize('NFKC') to fold away the decorative Unicode letters people paste into profile fields. It works. Mostly.

It does not work on the style people actually use.

I run a small Unicode text generator. In late August I added an event that records which style gets copied and how many characters were in it — no text content, just the style key and a length. Two weeks of that data turned out to disagree with how I had built the site, and chasing the disagreement led somewhere more interesting than the analytics.

What people copy

2026-08-25 to 2026-09-07 · 174 copy events · one site

That is a small sample, and I want it stated up front rather than buried in a footnote. It is one tool, two weeks, a few hundred visitors, with a known bot filtered out. Do not take the percentages as a measurement of the internet. Take them as the thing that made me go read the code point tables, which is where the actually durable findings are.

StyleCopiesShare
Small caps (ꜱᴍᴀʟʟ ᴄᴀᴘꜱ)12571.8%
Superscript (ˢᵘᵖᵉʳ)158.6%
Everything else (15 styles)3419.5%

The site offers about fifty styles — math bold, fraktur, script, double-struck, bubble, full-width, the usual set. Two of them account for 80.4% of all copies. The distribution is not a long tail; it is a spike with debris.

Median length was short, too: 2,393 characters across 174 copies, so 13.8 characters per copy. Nobody is styling paragraphs. They are styling a display name, a bio section header, a rank label.

So what is “small caps,” exactly

Small capitals are uppercase letterforms drawn at roughly lowercase height. In typography it is a font feature. In Unicode it is not a font feature, which is exactly why it survives a paste into a field that never let you choose a typeface.

But there is no “small caps block.” I dumped the 26 letters my own converter emits and looked at where each one lives:

Source blockLetters
Phonetic Extensions (U+1D00–)14
IPA Extensions (U+0250–)8
Latin Extended-D (U+A730–)2 — ꜰ and ꜱ
Latin Extended-B1 — the letter q
Basic Latin1 — the letter x

The alphabet is scavenged from five different blocks, and the last two rows are not small capitals at all:

  • There is no small capital X in Unicode. Not “poorly supported” — it does not exist. We emit a plain lowercase x, which is what people who work with these characters recommend, because every look-alike substitute sits at the wrong height and draws more attention than the honest letter.
  • For q there is (U+A7AF), a genuine small capital Q, but it is recent enough that a lot of fonts cannot draw it and you get a missing-glyph box. Most generators, mine included, fall back to ǫ (U+01EB) — which is a Latin small letter o with ogonek, wearing a hat. If you have seen a fancy-text tool produce a Q that looks subtly wrong, this is why.

The bulk of it — 22 of 26 letters — comes from two blocks built for phonetic notation. These are IPA symbols. ɪ is not a decorative i; it is the near-close near-front unrounded vowel. It has been sitting in the standard for linguists, and it got conscripted into Instagram bios.

That detail is not trivia. It is the reason for everything below.

Finding 1: NFKC does not touch it

Compatibility normalization folds characters that are “the same character in a different presentation” back to a base form. Math bold 𝐚 decomposes to a, because that is precisely what it is — an a with a formatting distinction the standard chose to encode separately for mathematicians.

IPA letters have no such decomposition, because they are not presentational variants of anything. ɢ is its own letter with its own meaning. Unicode is being correct here.

I ran every style through NFKC:

Stylenormalize('NFKC')
math bold, italic, script, fraktur, double-struck, monospace, sans, circled, full-widthfully restored to abcdefghijklmnopqrstuvwxyz
superscript, subscriptpartially changed
small capscompletely unchanged
'𝐚𝐛𝐜𝐝𝐞𝐟'.normalize('NFKC')   // 'abcdef'   ← folded
'ᴀʙᴄᴅᴇꜰ'.normalize('NFKC')   // 'ᴀʙᴄᴅᴇꜰ'   ← untouched

So if your defense against decorative-Unicode display names is NFKC, you are folding away the styles that make up under 20% of real use and leaving the 72% case completely intact. The one style that most reliably renders across platforms, is most legible at username size, and is by a wide margin the most copied, is also the one that walks straight through the filter.

This is not a Unicode bug and I do not think it should be “fixed” — collapsing IPA letters into ASCII would break actual linguistics. It just means NFKC is the wrong tool for this job, and it fails in a way that looks like success. If you need to reject decorative text, you need an explicit script or code-point allowlist, not a normalization pass.

The other thing I checked was encoding cost. Take the 26-letter lowercase alphabet through each style:

StyleUTF-16Code pointsUTF-8 bytes
ASCII baseline262626
Small caps262667
Superscript262670
Subscript322684
Math bold / italic / script / fraktur / mono5226104

Math alphanumerics live above U+FFFF. Every letter is a surrogate pair, so String.length — and every platform character counter built on UTF-16 units — sees two per letter. Of the 20 mapped styles in my font table, 15 (75%) produce surrogate pairs.

But small caps and superscript, the two styles carrying 80% of real usage, are entirely inside the BMP. One UTF-16 unit per letter. String.length reports exactly what the user sees.

I did not plan this and I do not think users did either. My guess is it is survivorship: BMP characters render on more devices and more platform fonts, so the styles that “just work” everywhere are the ones that get adopted, and those are the same ones that happen to be cheap. The failure mode selects for the cheap characters.

For anyone counting characters, the practical consequence:

  • Discord’s About Me caps at 190 characters. In small caps that is 190 letters. In math bold it is 95.
  • A VARCHAR(50) holding UTF-8 fits 50 ASCII letters, 19 small caps letters, or 12 math bold letters.
  • A user pasting math bold into a field and watching the counter jump by two per keystroke is not a bug report you want to debug from scratch.

The most common real-world symptom of this is people reporting that a bio field shows a negative remaining count after they paste styled text. The counter is measuring UTF-16 units against a limit the user assumes is characters.

What I would tell you to take from this

The usage numbers are one site and two weeks; treat them as a hint, not a result. Everything after them is deterministic and you can reproduce it in a REPL in thirty seconds — the block distribution, the NFKC behavior, the surrogate-pair arithmetic. That part does not depend on my traffic.

The part I did not expect: the most-used decorative Unicode style is simultaneously the one that is hardest to normalize away and cheapest to store, and it got both of those properties by accident, because it was designed for phonetic transcription by people who were not thinking about Instagram at all.


The tool this data came from is a small caps generator; the conversion runs in the browser and the analytics event records only the style key and a character count, never the text. The gaps described above — no X, the q substitution — are documented on the tool page itself rather than papered over.

Try it now — Small Caps Generator
Open tool →
← All guides