Text vs Emoji Presentation Selectors
Many Unicode characters have two visual forms: a monochrome text symbol and a full-color emoji. The character ☎ (U+260E BLACK TELEPHONE) can appear as ☎︎ (text, black outline) or ☎️ (emoji, with color). The choice is controlled by variation selectors — invisible Unicode code points that follow the base character.
Understanding variation selectors is essential for consistent rendering, correct text processing, and accurate emoji detection.
The Two Emoji Variation Selectors
| Selector | Code Point | Name | Effect |
|---|---|---|---|
| VS15 | U+FE0E | VARIATION SELECTOR-15 | Forces text presentation |
| VS16 | U+FE0F | VARIATION SELECTOR-16 | Forces emoji presentation |
These selectors are combining characters — they apply to the immediately preceding base character and are not displayed themselves.
Examples
# Python
text_phone = "\u260E\uFE0E" # ☎ + VS15 → ☎︎ (text)
emoji_phone = "\u260E\uFE0F" # ☎ + VS16 → ☎️ (emoji)
default_phone = "\u260E" # ☎ → ☎ (platform default: text)
# These look different in supporting renderers:
print(text_phone) # ☎︎ monochrome
print(emoji_phone) # ☎️ colored
print(default_phone) # ☎ (depends on platform)
const textPhone = "\u260E\uFE0E"; // ☎︎
const emojiPhone = "\u260E\uFE0F"; // ☎️
// Note: these are different strings
console.log(textPhone === emojiPhone); // false
console.log(textPhone.length); // 2
console.log(emojiPhone.length); // 2
Which Characters Have Dual Presentations?
The Unicode Standard defines an Emoji_Presentation property and a separate table of characters that support both text and emoji presentation — characters that are Emoji=Yes but Emoji_Presentation=No. These are text-default emoji: they render as text symbols unless followed by VS16.
Common text-default emoji include:
| Character | Code Point | Default | With VS16 |
|---|---|---|---|
| ☎ | U+260E | ☎ text | ☎️ emoji |
| ⌚ | U+231A | ⌚ text | ⌚️ emoji |
| ✂ | U+2702 | ✂ text | ✂️ emoji |
| ❤ | U+2764 | ❤ text | ❤️ emoji |
| ✈ | U+2708 | ✈ text | ✈️ emoji |
| ☀ | U+2600 | ☀ text | ☀️ emoji |
| © | U+00A9 | © text | ©️ emoji |
| ® | U+00AE | ® text | ®️ emoji |
| ‼ | U+203C | ‼ text | ‼️ emoji |
The full list is in emoji-variation-sequences.txt in the Unicode UCD.
Fully-Qualified vs Minimally-Qualified Emoji
The Unicode standard defines fully-qualified emoji as those with all required variation selectors present. The emoji-test.txtemoji-test.txt file marks each emoji sequence as:
ไฟล์ Unicode อย่างเป็นทางการที่แสดงรายการลำดับอิโมจิทั้งหมด พร้อมสถานะ qualification โค้ดพอยท์ และชื่อย่อ CLDR
fully-qualified: All VS16 selectors present (e.g., ❤️ = U+2764 + U+FE0F)minimally-qualified: VS16 absent from one or more expected positionsunqualifiedUnqualified: No variation selectors
ลำดับอิโมจิที่ขาด variation selector ที่จำเป็น ซึ่งอาจไม่แสดงผลเป็นอิโมจิในทุกแพลตฟอร์ม
# From emoji-test.txt:
2764 FE0F ; fully-qualified # ❤️ E0.6 red heart
2764 ; unqualified # ❤ E0.6 red heart
For reliable emoji lookup and comparison, normalize all emoji to their fully-qualified form.
Detecting and Normalizing Variation Selectors
Strip All Variation Selectors (Python)
import regex
VS15 = "\uFE0E"
VS16 = "\uFE0F"
def strip_variation_selectors(text: str) -> str:
"""Remove VS15 and VS16 from text."""
return text.replace(VS15, "").replace(VS16, "")
def normalize_to_emoji(text: str) -> str:
"""
Convert text-default emoji to emoji presentation
where a VS16 form exists.
"""
# Strip any existing selectors first
clean = strip_variation_selectors(text)
# Add VS16 after each character that has an emoji presentation
result = []
for char in clean:
result.append(char)
# Check if this character has an emoji VS16 form
if has_emoji_presentation(char):
result.append(VS16)
return "".join(result)
def has_emoji_presentation(char: str) -> bool:
"""Return True if char has a VS16 emoji presentation form."""
TEXT_DEFAULT_EMOJI = {
"\u00A9", "\u00AE", "\u203C", "\u2049", "\u2122",
"\u2139", "\u2194", "\u2195", "\u2196", "\u2197",
"\u2198", "\u2199", "\u21A9", "\u21AA", "\u231A",
"\u231B", "\u2328", "\u23CF", "\u260E", "\u2611",
"\u2614", "\u2615", "\u2618", "\u261D", "\u2620",
"\u2622", "\u2623", "\u2626", "\u262A", "\u262E",
"\u262F", "\u2638", "\u2639", "\u263A", "\u2640",
"\u2642", "\u2648", "\u2702", "\u2708", "\u2764",
# ... full list from emoji-variation-sequences.txt
}
return char in TEXT_DEFAULT_EMOJI
# Examples
print(strip_variation_selectors("❤️")) # ❤ (just U+2764)
print(strip_variation_selectors("☎︎")) # ☎ (just U+260E)
print(normalize_to_emoji("❤")) # ❤️ (adds VS16)
JavaScript Implementation
const VS15 = '\uFE0E';
const VS16 = '\uFE0F';
function stripVariationSelectors(str) {
return str.replace(/[\uFE0E\uFE0F]/g, '');
}
function getVariationSelectorType(char) {
if (char === VS15) return 'text';
if (char === VS16) return 'emoji';
return null;
}
// Check if a string position has a variation selectorVariation Selector (VS)
อักขระ Unicode (VS-15 U+FE0E และ VS-16 U+FE0F) ที่กำหนดว่าอักขระจะแสดงผลเป็นข้อความ (สีเดียว) หรืออิโมจิ (มีสี) following it
function getPresentation(text, index) {
const nextChar = text[index + 1];
if (nextChar === VS16) return 'emoji';
if (nextChar === VS15) return 'text';
// No selector — check Emoji_Presentation property
const char = String.fromCodePoint(text.codePointAt(index));
return /^\p{Emoji_Presentation}$/u.test(char) ? 'emoji' : 'text';
}
// Normalize: ensure emoji presentation for emoji-capable characters
const TEXT_DEFAULT_EMOJI = new Set([
'\u00A9', '\u00AE', '\u203C', '\u2122', '\u2139',
'\u260E', '\u2702', '\u2708', '\u2764',
// ... full set
]);
function ensureEmojiPresentation(text) {
let result = '';
for (const char of text) {
result += char;
if (char !== VS15 && char !== VS16 && TEXT_DEFAULT_EMOJI.has(char)) {
result += VS16;
}
}
return result;
}
Impact on String Comparison
Variation selectors make otherwise-identical-looking strings unequal:
heart_no_vs = "❤" # U+2764
heart_vs16 = "❤️" # U+2764 + U+FE0F
print(heart_no_vs == heart_vs16) # False
print(len(heart_no_vs)) # 1
print(len(heart_vs16)) # 2
# For comparison purposes, normalize first:
def normalize_emoji_for_compare(text: str) -> str:
return strip_variation_selectors(text)
print(normalize_emoji_for_compare("❤️") == normalize_emoji_for_compare("❤")) # True
This matters for:
- Database lookups: Searching for ❤ won't match ❤️ stored with VS16
- Emoji counting: ❤ and ❤️ should count as the same emoji
- Deduplication: Two users reacting with ❤ and ❤️ should be merged
Variation Selectors in Keycap Sequences
Digit emoji (1️⃣ through 9️⃣, 0️⃣, #️⃣, *️⃣) are three-code-point sequences:
1️⃣ = U+0031 (digit "1") + U+FE0F (VS16) + U+20E3 (combining enclosing keycap)
The VS16 here is mandatory — without it, the keycap sequence is malformed in many renderers:
keycap_correct = "1\uFE0F\u20E3" # 1️⃣ — correct
keycap_malformed = "1\u20E3" # 1⃣ — may not render correctly
# Fully-qualified check
import emoji
print(emoji.is_emoji(keycap_correct)) # True
print(emoji.is_emoji(keycap_malformed)) # Varies by library version
CSS and HTML Implications
In HTML, browsers generally handle variation selectors automatically. However, when you construct text dynamically via JavaScript or server-side templates, you must include VS16 explicitly for text-default emoji:
<!-- These may render differently depending on browser/OS -->
<span>❤</span> <!-- text form — black outline on some platforms -->
<span>❤️</span> <!-- emoji form — red heart everywhere -->
<!-- In JavaScript templates -->
const heartEmoji = "\u2764\uFE0F"; // Always emoji presentation
element.textContent = `I ${heartEmoji} JavaScript`;
Explore More on EmojiFYI
- See the raw code points including variation selectors: Sequence Analyzer
- Compare presentation across platforms: Compare Tool
- Unicode emoji terminology reference: Glossary
- Programmatic access to presentation data: API Reference