🍱 Lunchbox Hands

unicode

Why Emoji Look Different (or Broken) on Other Devices

Why emoji look different on iPhone and Android, why some show as empty boxes, and why family emoji fall apart into separate people — ZWJ and VS16 explained.

You send 👨‍👩‍👧 and your friend receives three separate people standing in a row. You paste ❤️ into a form and it comes back as a flat black heart. You use the newest emoji your phone offers and someone sees an empty rectangle. Unicode standardizes what an emoji means; every vendor draws its own picture, and a surprising share of the emoji you use every day aren’t one character at all — they’re several characters glued together by invisible ones. When that glue survives the trip, you see one image. When it doesn’t, you see the parts.

Here’s what’s actually in the string.

Same code point, different picture

U+1F600 is GRINNING FACE. That’s all Unicode says. It does not specify the color of the face, the shape of the mouth, or whether the eyes are open — those come from whatever emoji font the receiving device has installed: Apple Color Emoji on iOS and macOS, Noto Color Emoji on Android and most Linux distros, Segoe UI Emoji on Windows, and vendor-specific sets on Samsung, WhatsApp, Slack, X, and Facebook. Each is an independent illustration of the same abstract concept.

Most of the time this is harmless. Occasionally it isn’t: 😬 has read as a grimace on one platform and a grin on another, and the pistol emoji U+1F52B was redrawn as a water pistol by Apple in 2016 and by the other major vendors through 2018 — then X changed it back to a realistic gun in 2024. Same code point, opposite message. There is no “correct” rendering to enforce. You are shipping a code point and hoping. Worth remembering when you browse the Emoji Picker or any other grid of emoji: what you see there is your own machine’s font, which is the one rendering you can’t use to predict anyone else’s.

Half your emoji are not one character

This is the part that explains almost every “it broke on their phone” report. Unicode builds compound emoji out of existing ones using invisible connective characters. Every code point below is exact:

What you seeWhat it actually isCode points
👨‍👩‍👧 familyman + woman + girl, joinedU+1F468 U+200D U+1F469 U+200D U+1F467
🏳️‍🌈 rainbow flagwhite flag + rainbow, joinedU+1F3F3 U+FE0F U+200D U+1F308
🧑‍🍳 cookperson + cooking, joinedU+1F9D1 U+200D U+1F373
👍🏽 thumbs up, mediumthumbs up + skin-tone modifierU+1F44D U+1F3FD
❤️ red heartheavy black heart + emoji presentationU+2764 U+FE0F
🇺🇸 US flagregional indicators U and SU+1F1FA U+1F1F8
1️⃣ keycapdigit one + emoji presentation + enclosing keycapU+0031 U+FE0F U+20E3
🏴󠁧󠁢󠁥󠁮󠁧󠁿 Englandblack flag + the tag characters gbeng + cancelU+1F3F4 U+E0067 U+E0062 U+E0065 U+E006E U+E0067 U+E007F

Four mechanisms are doing the work:

ZWJ sequences. U+200D ZERO WIDTH JOINER sits between two emoji and asks the font to render them as a single ligature. 👨‍👩‍👧 is literally 👨 + ZWJ + 👩 + ZWJ + 👧. It’s the same family of invisible character covered in invisible Unicode characters — the ones that render as nothing and wreck string comparisons — except here the nothingness is load-bearing.

Skin-tone modifiers. U+1F3FB through U+1F3FF are the five Fitzpatrick modifiers (EMOJI MODIFIER FITZPATRICK TYPE-1-2 through TYPE-6). They attach to the preceding human emoji. 👍🏽 is thumbs-up followed by U+1F3FD, and nothing more.

Variation selectors. Characters that predate emoji — hearts, warning signs, dingbats — default to a monochrome text presentation. U+FE0F VARIATION SELECTOR-16 requests the colorful emoji glyph; U+FE0E VARIATION SELECTOR-15 requests the text one. That is the entire difference between ❤ (U+2764, one character) and ❤️ (U+2764 U+FE0F, two). Strip the VS16 — and plenty of sanitizers, length-truncators, and older text fields do — and a red heart turns black.

Regional indicators. There are no country-flag code points. U+1F1E6–U+1F1FF are 26 letter-shaped symbols, and a pair of them spelling an ISO 3166-1 country code is what a font ligates into a flag. 🇺🇸 is the letters U and S.

Why a sequence “falls apart”

A ZWJ sequence is a request. If the receiving font has a ligature glyph for that exact sequence, you get one picture. If it doesn’t, the renderer falls back to the only sane option: draw each component and leave the invisible joiner invisible. 🧑‍🍳 degrades to a person next to a frying pan. 🏳️‍⚧️ (U+1F3F3 U+FE0F U+200D U+26A7 U+FE0F) degrades to a white flag next to a transgender symbol. Nothing is corrupted — the receiver simply lacks the combined glyph, and the pieces are still perfectly valid emoji.

This is why newer compound emoji are the fragile ones. 🫱🏻‍🫲🏽 (a handshake with two different skin tones — U+1FAF1 U+1F3FB U+200D U+1FAF2 U+1F3FD) stacks modifiers and a joiner, so it has more ways to degrade than any single-code-point emoji ever will.

Flags fail differently, and predictably: Segoe UI Emoji on Windows ships no national flag ligatures at all, so 🇺🇸 renders as two boxed letters, U S. That’s a deliberate Microsoft decision, not a bug, and it’s why Firefox on Windows shows flags correctly — Firefox bundles its own Twemoji-based font rather than using the system one.

Why brand-new emoji show a box

The empty rectangle (☐, “tofu”) means the font has no glyph for that code point — the device’s emoji font is older than the emoji. And the lag is long, because emoji fonts ship with OS updates.

A concrete round trip: Emoji 16.0 was published on 10 September 2024 with eight new emoji, including 🫩 U+1FAE9 face with bags under eyes. Apple shipped them to iPhones in iOS 18.4 on 31 March 2025 — roughly seven months later, and only for users who took the update. Unicode 17.0 followed on 9 September 2025 with Emoji 17.0: 7 new single-code-point emoji and 156 new sequences, rolling out across platforms through 2026.

So an emoji that exists on your phone can be a blank box on a colleague’s three-year-old Android, a laptop that hasn’t been updated, or inside an app that bundles a frozen emoji font. The safe set is always older than what your keyboard offers.

Your string length is wrong, and so is your truncation

JavaScript strings are UTF-16, so .length counts 16-bit code units — not characters, and definitely not “emoji”. Spreading the string gives code points. Only Intl.Segmenter gives you what a human calls one character. Every number in this table was measured, not estimated:

String.length[...s].lengthIntl.Segmenter graphemes
😀211
❤️221
👍🏽421
🇺🇸421
🏳️‍🌈641
👨‍👩‍👧851
🏴󠁧󠁢󠁥󠁮󠁧󠁿1471
const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...seg.segment('👨‍👩‍👧')].length; // 1
'👨‍👩‍👧'.length;                    // 8

Intl.Segmenter has been Baseline-available since Firefox 125 shipped on 16 April 2024, joining Chrome 87, Edge 87, and Safari 14.1 — use it for anything user-facing.

The practical damage is in truncation. '👨‍👩‍👧'.slice(0, 2) returns a lone 👨 — you cut the string between a joined component and its ZWJ. '🇺🇸'.slice(0, 2) returns 🇺, a bare regional indicator that renders as a boxed letter U. A 140-character limit enforced with .length silently costs a user eight “characters” for one family emoji, and a byte-based limit is worse: most emoji are 4 bytes in UTF-8, and a column declared utf8 in older MySQL is three-byte utf8mb3, which cannot store them at all. Tokenizers count them their own third way — see how LLM tokenization works for why one emoji can cost several tokens.

How to inspect what you actually pasted

When an emoji misbehaves, stop guessing and read the code points. Paste it into the Unicode Inspector: it lists every code point in the string as U+XXXX with its decimal value and UTF-8 and UTF-16 byte sequences. Every table in this post is the kind of output it gives you.

Three things to look for:

  • A U+200D between two emoji — it’s a ZWJ sequence, so expect it to degrade into its components somewhere.
  • A missing U+FE0F — if your heart, warning sign, or keycap renders monochrome, the variation selector got stripped in transit. Re-copy it from a source that keeps it.
  • One code point above U+FFFF and nothing else — congratulations, that’s the durable kind.

For the opposite problem — invisible characters you didn’t mean to paste, like the zero-width space, word joiner, and Hangul filler that the invisible character tool generates for blank bios and usernames — the inspector is the same first move, and the invisible Unicode characters post covers removing them.

What to do

  • Pick single-code-point emoji from older Unicode versions when the audience is broad. 😀 🔥 ✅ 👍 render everywhere. 👨‍👩‍👧 🫱🏻‍🫲🏽 and anything from the last two Emoji releases do not.
  • Never truncate or measure user text with .length. Use Intl.Segmenter with granularity: 'grapheme'.
  • Store text as utf8mb4 / genuine UTF-8, and don’t run emoji through sanitizers that filter “non-printing” characters — they eat the ZWJ and VS16 that hold sequences together.
  • Avoid flags in anything cross-platform. Windows shows letters, not flags, and country flags carry political baggage no font can fix.
  • Inspect before you debug. One paste into the Unicode Inspector answers “is this one emoji or five?” in a second.
  • Copy from a source that preserves the full sequence. The Emoji Picker searches roughly 1,900 emoji by name or by Unicode group — Smileys & Emotion, People & Body, Flags, and the rest — and copies the fully-qualified form, VS16 and joiners intact, so ❤️ arrives as U+2764 U+FE0F rather than the bare heart. It includes compound entries like 🏳️‍🌈 and 👨‍👩‍👧 and recent additions like 🫩, which is exactly the set you want to check against a second device before shipping.

And if what you’re pasting isn’t an emoji but a “font” — 𝐛𝐨𝐥𝐝 or 𝓼𝓬𝓻𝓲𝓹𝓽 text from a generator — that’s the same substitution trick with worse consequences for screen readers: see fancy fonts are not fonts. Both problems come from the same place: Unicode encodes characters, not pictures, and the picture is always someone else’s decision.