Skip to content
OneKitly

Emoji Are Harder Than They Look: Why "Just Strip the Emoji" Has No One-Line Answer

Published 9/29/2025 · 13 min read · Text & language tools

Daniel Okonkwo

Daniel OkonkwoFront-end developer and tech writer at OneKitly

Web performance · File formats

Checked against 5 sources

View profile
In short

There is no simple regular expression for removing emoji because there is no simple definition of what one emoji is. A single picture on screen can be one code point (😀 is U+1F600), a base character plus an invisible variation selector (❤️ is U+2764 U+FE0F), a base plus a skin-tone modifier (👍🏽 is U+1F44D U+1F3FD), a chain of characters joined by zero-width joiners (👨‍👩‍👧‍👦 is seven code points and eleven UTF-16 units), a pair of regional indicators (🇵🇹 is U+1F1F5 U+1F1F9), or a tag sequence of seven code points. The properties people reach for do not line up either: \p{Emoji} matches the ASCII digits 0 to 9 as well as # and *, so using it to clean a sentence deletes the numbers — we ran it and "2026 revenue up 40%" came back as " revenue up %". \p{Extended_Pictographic} is the right property for pictures but does not cover skin tones or flags. The rule that works is to segment the text into grapheme clusters, then drop whole clusters. The same rule fixes truncation, where cutting at a code-unit index can split a family of four into two unrelated people or leave a lone surrogate that becomes U+FFFD.

One visible emoji can be one code point or fourteen UTF-16 units. We ran three popular regexes against a real sentence and each broke differently — one deleted the digits. Here is why, which Unicode property answers which question, and the grapheme-cluster rule that actually works.

One picture, six different constructions

Ask a program how long an emoji is and you get three different answers depending on the unit. We measured the same eight emoji three ways: in UTF-16 code units, which is what JavaScript's .length reports; in code points; and in grapheme clusters, which is what a reader would call one character. The grapheme count is 1 for every single one of them. The other two counts range from 1 to 14.

The simple case is a single code point: 😀 is U+1F600, two UTF-16 units because it is outside the Basic Multilingual Plane. Then it gets interesting. ❤️ is U+2764 followed by U+FE0F, an invisible variation selector whose only job is to say "draw the previous character as a picture, not as a symbol". 1️⃣ is three code points: the ASCII digit 1, the same variation selector, and U+20E3 COMBINING ENCLOSING KEYCAP. 👍🏽 is a thumbs-up followed by U+1F3FD, a skin-tone modifier that is itself a valid character and renders as a coloured square when it stands alone.

The hard cases are sequences. 👨‍👩‍👧‍👦 is a man, a woman, a girl and a boy separated by three copies of U+200D ZERO WIDTH JOINER: seven code points, eleven UTF-16 units, one picture. 👩‍💻 is a woman plus a laptop joined the same way, and 🏴‍☠️ is a black flag joined to a skull and crossbones with a variation selector on the end. Flags work differently again: 🇵🇹 is not a flag character at all but the pair of regional indicators U+1F1F5 U+1F1F9, which are the letters P and T in a special alphabet, and 🏴󠁧󠁢󠁳󠁣󠁴󠁿 is a black flag followed by five invisible tag characters spelling out a subdivision code, then a terminator — seven code points, fourteen UTF-16 units.

The property that looks right and deletes your numbers

JavaScript, and every regex engine that supports Unicode property escapes, offers \p{Emoji}. It is the obvious choice and it is the wrong one. Emoji is a character property that means "this character may participate in an emoji", and the digits 0 through 9 participate — they are the bases of the keycap sequences. So do # and *, for the same reason.

The consequence is easy to demonstrate. Take the sentence "2026 revenue up 40%", which contains no emoji at all, and run replace(/\p{Emoji}/gu, ""). It returns " revenue up %". Every digit is gone; the per-cent sign, which is not an emoji component, survives. The same expression turns "Order #7 shipped ✅" into "Order shipped ". Run \p{Extended_Pictographic} on the first sentence instead and it comes back untouched, which is the behaviour you wanted.

But Extended_Pictographic is not a complete answer either, because it means "this character is a pictograph" and several parts of an emoji are not pictographs. We tested each part: the skin-tone modifier U+1F3FD is not Extended_Pictographic, and neither are the regional indicators that make up a flag. Run Extended_Pictographic alone over a sentence with 🇵🇹 and 👍🏽 in it and the flag survives whole while the thumb vanishes and leaves its skin tone behind as a lone coloured square.

Emoji_Presentation is the property that answers "will this render as a picture"

Some characters default to a coloured picture and some default to monochrome text. That distinction is exactly what Emoji_Presentation records. We probed a set of characters and the split is clean: 😀, ⌚, 👍 and 🀄 have Emoji_Presentation, so they draw as pictures with nothing extra. ❤, ☺, ▶, ✔, ©, ® and ™ do not; left alone they draw as text glyphs in the surrounding font.

This is why the variation selector exists. U+FE0F is the request to render the previous character as an emoji, and it is what turns ❤ into ❤️ and ▶ into ▶️. It is also invisible, weightless in a reader's mental model, and a real character in the string, which is why a regex that removes the pictograph and not its selector leaves a stray U+FE0F behind. We measured that: "done ❤️" with Extended_Pictographic stripped comes back as the six code points d, o, n, e, space, U+FE0F. The result looks clean and is not.

Three naive regexes, three different failures

We took one sentence — "Shipping to 🇵🇹 today 👨‍👩‍👧‍👦 — 40% off, thanks 👍🏽 #7 ❤️", which is 63 UTF-16 units, 55 code points and 46 grapheme clusters — and ran the three approaches people usually reach for, plus a fourth built on grapheme clusters.

The code-point range approach, /[\u{1F300}-\u{1FAFF}]/gu, is the one you find in half the answers online. It removed the family members and the thumb, but the flag survived because regional indicators live at U+1F1E6–U+1F1FF, below the range; the heart survived because U+2764 is far below it; and the three zero-width joiners that held the family together were left in the string as invisible garbage.

The \p{Emoji} approach removed the flag and the family and the thumb, and also removed 40 and the 7 from #7, leaving "% off" and a bare hash. The Extended_Pictographic approach kept the flag intact, removed the thumb but not its skin tone, and left the joiners and the variation selector. Three plausible one-liners, three distinct kinds of wrong output, none of them announcing a problem.

The fourth approach segments the text into grapheme clusters with Intl.Segmenter, then discards a whole cluster when it contains a regional indicator, a character with Emoji_Presentation, or a pictograph followed by the variation selector. On the same sentence it returned "Shipping to today — 40% off, thanks #7 " — the digits kept, the flag gone as a unit, no orphaned joiners, no stray selector. The only artefact is the double spaces where the emoji used to be, which a whitespace collapse fixes in a second pass.

Truncation is where this bites in production

Cutting a string at N units is the single most common way emoji get broken, because it is the cheapest thing to write and it works on every test string an English-speaking developer thinks to try. Take "Great work 👨‍👩‍👧‍👦 thanks", 29 UTF-16 units. Slicing at 12, 15 or 18 ends the string on a lone high surrogate — half of a character, which is not valid text. Send it through UTF-8 and it becomes the three bytes EF BF BD, the replacement character, so the reader sees a black diamond. JavaScript's isWellFormed() returns false for that string, which is a cheap way to catch the bug in a test.

The subtler failure is worse because it produces valid text that means something else. Slicing the same string at 16 units gives "Great work 👨‍👩" — the family of four has become a man and a woman with a joiner between them, which is not a defined sequence, so it renders as two separate people. Nothing is malformed. Nothing throws. The message now shows a different picture from the one that was sent.

Iterating by code point with the spread operator fixes the lone-surrogate problem and not the other one: taking the first 14 code points of the same string still gives "Great work 👨‍👩". Only grapheme clusters get it right, because they are the unit the segmentation algorithm defines as one user-perceived character. Taking the first 14 grapheme clusters gives "Great work 👨‍👩‍👧‍👦 t" — the whole family kept as one unit, which is what a reader would expect from a fourteen-character preview.

Counting emoji has the same problem as removing them

Take the string "🇵🇹 👨‍👩‍👧‍👦 👍🏽 ❤️ 😀". A human counts five emoji. We asked six different methods and got six different answers: 27 UTF-16 units, 18 code points, 9 grapheme clusters, 5 non-blank grapheme clusters, 7 matches of \p{Extended_Pictographic}, 9 matches of \p{Emoji_Presentation} and 10 matches of \p{Emoji}. Only one of those is 5.

The property counts are high for exactly the reasons above: the family contributes four pictographs, the thumb contributes one and its skin tone another under Emoji_Presentation, and the flag contributes two. If your product enforces a rule like "at most three emoji per post", the rule is only as meaningful as the counter behind it, and the counter has to be the one that agrees with the reader — grapheme clusters that contain an emoji character.

What removing non-ASCII does instead, and why it is not the same job

A tempting shortcut is to keep only ASCII, on the grounds that all emoji are outside it. All emoji are, and so is most of the rest of the world. We ran replace(/[^\x00-\x7F]/g, "") over "Café ☕ — résumé sent 👍" and it returned "Caf rsum sent ": the coffee cup and the thumb are gone, and so are the é in Café, both accents in résumé, and the em dash. On a French, Spanish, Portuguese, German or Italian sentence this destroys ordinary words, not decoration.

The two operations belong in different tools for a reason. Removing emoji means "take out the pictures and leave the language alone". Removing non-ASCII means "reduce this to the 128 characters an old system can handle", which is a transliteration job with real losses that you should be choosing on purpose. Reaching for the second when you wanted the first is one of the quieter ways a multilingual product breaks.

UTF-16 units
One visible emoji, measured three ways — Node 26, Intl.Segmenter with granularity "grapheme"
EmojiHow it is builtUTF-16 unitsCode pointsGrapheme clusters
😀A single code point, U+1F600211
❤️Base U+2764 plus variation selector U+FE0F221
1️⃣Digit 1, selector U+FE0F, keycap U+20E3331
👍🏽Base U+1F44D plus skin-tone modifier U+1F3FD421
🇵🇹Two regional indicators, U+1F1F5 U+1F1F9421
👨‍👩‍👧‍👦Four people joined by three U+200D joiners1171
🏴󠁧󠁢󠁳󠁣󠁴󠁿Black flag plus five tag characters plus a terminator1471
Remove emojisStrip all emojis and pictographs from your text.Try the tool

Frequently asked questions

What is the shortest regex that removes emoji correctly?
There is not one, and that is the honest answer. A regex matches code points, and an emoji is a sequence of code points whose boundaries are defined by a segmentation algorithm, not by a pattern. You can get close with a long alternation covering pictographs, joiner runs, regional-indicator pairs and modifiers, but you are then reimplementing the algorithm badly. Segment first, filter clusters second — that is three lines and it is right.
Why did stripping emoji leave an invisible character behind?
Because you removed the picture and not its companions. The two usual suspects are U+FE0F, the variation selector that asks for emoji rendering, and U+200D, the zero-width joiner that links the parts of a sequence. Neither is a pictograph, so a pictograph-based filter leaves them. They are invisible on screen but real in the string: they count against character limits, they break equality comparisons, and they will surprise the next person who diffs the text.
Why does the same emoji look different on another phone?
The string carries the identity, not the drawing. Every vendor ships its own emoji font, so U+1F600 is one artist's smile on one platform and another's somewhere else. When a device has no glyph for a sequence, it falls back to drawing the parts: a family that renders as one picture on your phone can appear as four separate people on an older device, which is the correct fallback behaviour for a joiner sequence rather than a bug.
How should a character limit count an emoji?
Count grapheme clusters if the limit exists for the reader, and count bytes if it exists for the storage. The two answers differ by a factor of eleven on a family emoji, so pick the one that matches the reason for the limit and say which it is next to the field. What you should never do is enforce a UTF-16 length silently, because a user typing three flags will hit a 30-character limit at twelve visible characters with no explanation.
Are © and ™ emoji?
By property, yes and no at the same time, which is exactly the confusion this article is about. We probed both: each has Emoji and Extended_Pictographic, but neither has Emoji_Presentation, so both render as ordinary text unless a variation selector asks for a picture. That means a filter built on Extended_Pictographic will delete the copyright symbol from your footer. If you only want the coloured pictures, test for Emoji_Presentation or for a pictograph immediately followed by U+FE0F.
Does removing emoji change the meaning of a message?
Often, which is an argument for stripping them at the edges of a system rather than in the middle. Emoji carry tone, negation and sometimes the entire content of a reply. Removing them is appropriate when the destination cannot render them — a plain-text export, a legacy database column, a printed report, a filename — and inappropriate when the text will still be read by a person. Strip on the way out, keep the original stored.

Articles you may find interesting

All guides
GuideFormatting Numbers for Six Languages: Separators, Currency and the Parse Back1,234.56 and 1.234,56 are the same number, and confusing them changes the value a reader parses. We ran Intl.NumberFormat for all six site locales and printed every separator — including the invisible one French uses — then measured why parseFloat cannot undo any of it.ExplainerCounting Words Is Ambiguous, and Every Tool Answers DifferentlyA word count is a definition, not a measurement. We counted the same paragraph four ways and got 25, 28, 33 and 38 — then counted 50,000 characters of ordinary prose and got agreement to within 4.5%. The gap is entirely driven by compounds, figures and URLs.How-toFiltering Lines by a Pattern Without a Command LineThis is grep for people who do not use grep, with one important difference: the match is a plain substring, so a real regular expression returns an empty box and no error. Every claim here was checked by running the tool.ExplainerWhere a Line May Break: The Unicode Algorithm Behind Every Wrapped Paragraph"Break at spaces" fails in most of the world's writing systems. UAX #14 gives every character a line-break class; we looked ours up in Unicode 17.0.0 and ran a conforming implementation over no-break spaces, soft hyphens, zero-width spaces, URLs, Japanese and Thai.ExplainerRemoving Accents Breaks Search — Until You Do It on Both SidesFolding diacritics is a normalisation step, and normalisation only works when the same function runs on the index and on the query. NFC against NFD with the code points shown, and the letters — ø, ł, ß, œ, ı — that survive the strip untouched.GuideConverting Between List Formats Without Losing Data: The Quoting Rules Nobody ReadsTurning a newline list into a comma list is trivial until an item contains a comma. RFC 4180's quoting rules, why a CSV field may contain a newline, why European spreadsheets use the semicolon, and what an empty item does to a round trip — every case run and printed.

Related tools

Sources

Spotted a mistake in this article?