Implementing Skin Tone Modifiers Programmatically
Skin tone modifiers let users personalize emojiEmoji
A Japanese word (絵文字) meaning 'picture character' — small graphical symbols used in digital communication to express ideas, emotions, and objects. to match their appearance. What looks like a single character — 👋🏽 — is actually two UnicodeUnicode
Universal character encoding standard that assigns a unique number to every character across all writing systems and symbol sets, including emoji. code points: a modifier base (👋 U+1F44B) followed by a skin tone modifierSkin Tone Modifier
Five Unicode modifier characters based on the Fitzpatrick scale that change the skin color of human emoji (U+1F3FB to U+1F3FF). (🏽 U+1F3FD). Implementing this correctly requires understanding which emoji accept modifiers, how to apply and strip them, and how to handle complex multi-person ZWJZero Width Joiner (ZWJ)
An invisible Unicode character (U+200D) used to join multiple emoji into a single composite emoji, such as combining people and objects into profession emoji. sequences.
The Fitzpatrick Scale
Unicode defines five skin tone modifier characters based on the Fitzpatrick dermatological scale:
| Modifier | Code PointCode Point A unique numerical value assigned to each character in the Unicode standard, written in the format U+XXXX (e.g., U+1F600 for 😀). |
Emoji | Fitzpatrick Type |
|---|---|---|---|
| Light | U+1F3FB | 🏻 | I–II (very light to light) |
| Medium-Light | U+1F3FC | 🏼 | III (light brown) |
| Medium | U+1F3FD | 🏽 | IV (moderate brown) |
| Medium-Dark | U+1F3FE | 🏾 | V (dark brown) |
| Dark | U+1F3FF | 🏿 | VI (deeply pigmented) |
When a modifier follows a valid modifier base, both are rendered as a single skin-toned glyph. The modifier has no visual representation on its own.
Modifier Base Detection
Not all person or hand emoji accept skin tone modifiers. The Unicode property Emoji_Modifier_Base determines which do. Key examples:
Accept modifiers: 👋 👍 👎 ✌️ 🤞 👌 🤌 👏 🙌 🤝 🙏 💪 🦾 🖕 🖖 👶 👦 👧 🧑 👱 👴 👵 🧓 👲 👳 🧔 💁 🙋 🤷 🙎 🙍
Do NOT accept modifiers: 👻 🤖 🦊 🙊 👀 🔥 ❤️ (non-human or non-body-part emoji)
Checking Modifier Base in Python
import regex
def is_modifier_base(char: str) -> bool:
"""Return True if the character can accept a skin tone modifier."""
return bool(regex.match(r'\p{Emoji_Modifier_Base}', char))
def is_skin_tone_modifier(char: str) -> bool:
"""Return True if the character is a skin tone modifier."""
return bool(regex.match(r'\p{Emoji_Modifier}', char))
# Examples
print(is_modifier_base("👋")) # True
print(is_modifier_base("🔥")) # False
print(is_skin_tone_modifier("🏽")) # True
print(is_skin_tone_modifier("😊")) # False
Checking in JavaScript
// ES2018+ Unicode property escapes
const isModifierBase = (char) => /^\p{Emoji_Modifier_Base}$/u.test(char);
const isModifier = (char) => /^\p{Emoji_Modifier}$/u.test(char);
console.log(isModifierBase("👋")); // true
console.log(isModifierBase("🔥")); // false
console.log(isModifier("🏽")); // true
Note: \p{Emoji_Modifier_Base} is not universally supported across all JavaScript engines. Test in your target environment or use a data-driven approach with a hardcoded set of modifier base code points.
Applying and Removing Modifiers
Apply a Modifier
SKIN_TONES = {
"light": "\U0001F3FB", # 🏻
"medium-light": "\U0001F3FC", # 🏼
"medium": "\U0001F3FD", # 🏽
"medium-dark": "\U0001F3FE", # 🏾
"dark": "\U0001F3FF", # 🏿
}
def apply_skin_tone(emoji: str, tone: str) -> str:
"""
Apply a skin tone modifier to a modifier-base emoji.
Removes any existing modifier first.
Returns the original emoji unchanged if it doesn't accept modifiers.
"""
if not is_modifier_base(emoji):
return emoji
# Strip existing modifier if present
base = strip_skin_tone(emoji)
modifier = SKIN_TONES.get(tone)
if modifier is None:
return base
return base + modifier
def strip_skin_tone(emoji: str) -> str:
"""Remove skin tone modifier from an emoji, returning the default form."""
modifier_range = regex.compile(r'[\U0001F3FB-\U0001F3FF]')
return modifier_range.sub('', emoji)
# Usage
print(apply_skin_tone("👋", "medium")) # 👋🏽
print(apply_skin_tone("👋🏻", "dark")) # 👋🏿 (replaces existing)
print(apply_skin_tone("🔥", "medium")) # 🔥 (unchanged, not a base)
print(strip_skin_tone("👍🏾")) # 👍
JavaScript Implementation
const SKIN_TONE_MODIFIERS = {
light: '\u{1F3FB}', // 🏻
mediumLight: '\u{1F3FC}', // 🏼
medium: '\u{1F3FD}', // 🏽
mediumDark: '\u{1F3FE}', // 🏾
dark: '\u{1F3FF}', // 🏿
};
const MODIFIER_REGEX = /[\u{1F3FB}-\u{1F3FF}]/gu;
function stripSkinTone(emoji) {
return emoji.replace(MODIFIER_REGEX, '');
}
function applySkinTone(emoji, tone) {
const base = stripSkinTone(emoji);
const modifier = SKIN_TONE_MODIFIERS[tone];
if (!modifier) return base;
// Check if base accepts modifiers (simplified check using known bases)
if (!/^\p{Emoji_Modifier_Base}/u.test(base)) return base;
return base + modifier;
}
console.log(applySkinTone("👋", "medium")); // 👋🏽
console.log(applySkinTone("👋🏻", "dark")); // 👋🏿
console.log(stripSkinTone("👍🏾")); // 👍
Multi-Person ZWJ Sequences and Skin Tones
Person emoji combined with ZWJ (like 👫 couple or 🤝 handshake) support independent skin tones per person. These are encoded as separate modifier bases joined by ZWJ, each with their own modifier:
👫 (couple) = 👩 + ZWJ + 👨
👩🏽🤝👨🏿 = 👩🏽 + ZWJ + 🤝 + ZWJ + 👨🏿
Parsing Multi-Person Sequences
def parse_emoji_components(zwj_sequence: str) -> list[str]:
"""
Split a ZWJ sequence into its constituent emoji components.
Returns list of emoji (without ZWJ characters).
"""
ZWJ = "\u200D"
return zwj_sequence.split(ZWJ)
def apply_skin_tones_to_zwj(sequence: str, tones: list[str]) -> str:
"""
Apply skin tones to each person component in a ZWJ sequence.
tones: list of tone names, one per modifiable component.
"""
ZWJ = "\u200D"
components = parse_emoji_components(sequence)
tone_iter = iter(tones)
result = []
for component in components:
if is_modifier_base(component) or is_modifier_base(strip_skin_tone(component)):
tone = next(tone_iter, None)
if tone:
result.append(apply_skin_tone(strip_skin_tone(component), tone))
else:
result.append(strip_skin_tone(component))
else:
result.append(component)
return ZWJ.join(result)
# Example: Two-person handshake with different skin tones
handshake_zwj = "🧑\u200D🤝\u200D🧑"
result = apply_skin_tones_to_zwj(handshake_zwj, ["medium", "dark"])
print(result) # 🧑🏽🤝🧑🏿
UI Implementation Patterns
Skin Tone Picker Component (React)
const TONES = [
{ id: null, label: "Default", swatch: "👋" },
{ id: "1F3FB", label: "Light", swatch: "👋🏻" },
{ id: "1F3FC", label: "Medium-Light", swatch: "👋🏼" },
{ id: "1F3FD", label: "Medium", swatch: "👋🏽" },
{ id: "1F3FE", label: "Medium-Dark", swatch: "👋🏾" },
{ id: "1F3FF", label: "Dark", swatch: "👋🏿" },
];
function SkinTonePicker({ value, onChange }) {
return (
<div role="radiogroup" aria-label="Skin tone">
{TONES.map(tone => (
<button
key={tone.id ?? 'default'}
role="radio"
aria-checked={value === tone.id}
aria-label={tone.label}
onClick={() => onChange(tone.id)}
className={`skin-tone-btn ${value === tone.id ? 'selected' : ''}`}
>
{tone.swatch}
</button>
))}
</div>
);
}
Applying the Selected Tone to Emoji Grid
function getEmojiWithTone(emojiData, skinToneModifier) {
if (!skinToneModifier) return emojiData.emoji;
// Use pre-computed skin variations from the dataset
const variation = emojiData.skinVariations?.[skinToneModifier];
if (!variation) return emojiData.emoji;
return String.fromCodePoint(
...variation.unified.split('-').map(h => parseInt(h, 16))
);
}
Persisting Skin Tone Preferences
Store the user's chosen skin tone in localStorage or a user profile:
const STORAGE_KEY = 'emoji-skin-tone';
function useSkinTone() {
const [tone, setTone] = useState(() => {
try {
return localStorage.getItem(STORAGE_KEY) || null;
} catch {
return null;
}
});
const updateTone = useCallback((newTone) => {
setTone(newTone);
try {
if (newTone) localStorage.setItem(STORAGE_KEY, newTone);
else localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore storage errors
}
}, []);
return [tone, updateTone];
}
Edge Cases
- Non-base emoji: Applying a modifier to 🔥, ❤️, or other non-base emoji should be a no-op — return the original.
- Already-modified emoji: Strip the existing modifier before applying a new one to avoid double modifiers (👋🏻🏽 is invalid).
- ZWJ sequence order: Modifiers must immediately follow the base — a ZWJ between base and modifier breaks the sequence.
- Handshake (🤝): U+1F91D is itself an
Emoji_Modifier_Basein newer Unicode versions, enabling toned handshake sequences.
Explore More on EmojiFYI
- See skin tone variants for any emoji: Sequence Analyzer
- Compare skin tone rendering across platforms: Compare Tool
- Unicode modifier properties explained: Glossary
- Query skin tone variant data: API Reference