Implementing Skin Tone Modifiers Programmatically

Implementing Skin Tone Modifiers Programmatically

Skin tone modifiers let users personalize emoji to match their appearance. What looks like a single character — 👋🏽 — is actually two Unicode code points: a modifier base (👋 U+1F44B) followed by a skin tone modifier (🏽 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)
อักขระ Unicode ที่มองไม่เห็น (U+200D) ใช้เพื่อเชื่อมอิโมจิหลายตัวเข้าเป็นอิโมจิรวม เช่น การรวมคนและวัตถุเป็นอิโมจิอาชีพ
sequences.

The Fitzpatrick Scale

Unicode defines five skin tone modifier characters based on the Fitzpatrick dermatological scale:

Modifier Code Point 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_Base in newer Unicode versions, enabling toned handshake sequences.

Explore More on EmojiFYI

เครื่องมือที่เกี่ยวข้อง

🔀 เปรียบเทียบแพลตฟอร์ม เปรียบเทียบแพลตฟอร์ม
เปรียบเทียบการแสดงผล emoji บน Apple, Google, Samsung, Microsoft และอื่นๆ ดูความแตกต่างด้านภาพแบบเคียงข้างกัน
🔍 ตัววิเคราะห์ลำดับ ตัววิเคราะห์ลำดับ
ถอดรหัสลำดับ ZWJ, ตัวปรับแต่งสีผิว, ลำดับ keycap และคู่ธงเป็นส่วนประกอบแต่ละชิ้น

คำในอภิธานศัพท์

Zero Width Joiner (ZWJ) Zero Width Joiner (ZWJ)
อักขระ Unicode ที่มองไม่เห็น (U+200D) ใช้เพื่อเชื่อมอิโมจิหลายตัวเข้าเป็นอิโมจิรวม เช่น การรวมคนและวัตถุเป็นอิโมจิอาชีพ
โค้ดพอยท์ โค้ดพอยท์
ค่าตัวเลขเฉพาะที่กำหนดให้กับอักขระแต่ละตัวในมาตรฐาน Unicode เขียนในรูปแบบ U+XXXX (เช่น U+1F600 สำหรับ 😀)
ตัวปรับโทนผิว ตัวปรับโทนผิว
อักขระตัวปรับแต่ง Unicode ห้าตัวที่อิงตามสเกล Fitzpatrick ใช้เปลี่ยนสีผิวของอิโมจิมนุษย์ (U+1F3FB ถึง U+1F3FF)
ยูนิโค้ด ยูนิโค้ด
มาตรฐานการเข้ารหัสอักขระสากลที่กำหนดหมายเลขเฉพาะให้กับอักขระทุกตัวในทุกระบบการเขียนและชุดสัญลักษณ์ รวมถึงอิโมจิ
อิโมจิ อิโมจิ
คำภาษาญี่ปุ่น (絵文字) แปลว่า 'อักขระภาพ' — สัญลักษณ์กราฟิกขนาดเล็กที่ใช้ในการสื่อสารดิจิทัลเพื่อแสดงความคิด อารมณ์ และวัตถุ

บทความที่เกี่ยวข้อง