04Tone Swap Where a Chosen Tone Value Redraws a Whole Emoji Set
A tone value chosen in the tone strip is stored, and every time a person-shaped emoji is drawn on screen that value is read alongside it so only the hand or face parts render in the chosen tone instead.
Best forReaction boards heavy with hand or face emoji
EMOJI_TONE_MODIFIERScombineBASES

<main class="stage">
<div class="tn is-demo" id="tn">
<div class="tn__strip" role="radiogroup" aria-label="Tone selection" id="tnStrip"></div>
<ul class="tn__grid" id="tnGrid"></ul>
<p class="tn__sr" role="status" aria-live="polite" id="tnLive"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html combine() 이 사람 형태 이모지의 코드포인트에 톤 수정자 코드포인트를 그대로
// 이어 붙여 새 문자를 만든다 — 미리 렌더한 다섯 벌 그림을 바꿔 끼우는 게 아니라 문자 자체를 조합한다.
@include stage($stage-paper);
.tn { width: min(100%, 320px); display: flex; flex-direction: column; gap: $sp-3; align-items: center; }
.tn__strip { display: flex; gap: $sp-1; }
.tn__swatch {
width: 22px; height: 22px; border-radius: 50%; border: none; padding: 0; cursor: pointer;
box-shadow: inset 0 0 0 2px rgba($subject-ink, .18); transition: transform $dur-quick $ease-spring, box-shadow $dur-quick $ease-out;
}
.tn__swatch--t0 { background: #dccbb8; }
.tn__swatch--t1 { background: #ffe0c4; }
.tn__swatch--t2 { background: #eae1d5; }
.tn__swatch--t3 { background: #d29b6a; }
.tn__swatch--t4 { background: #a86f43; }
.tn__swatch--t5 { background: #6b4527; }
.tn__swatch[aria-checked="true"] { transform: scale(1.18); box-shadow: inset 0 0 0 2px $subject-ink, 0 0 0 3px $subject-cream; }
.tn__grid {
list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(5, 1fr); gap: $sp-2;
width: 100%;
}
.tn__item { display: flex; align-items: center; justify-content: center; background: rgba($subject-ink, .06); border-radius: $r-sm; padding: $sp-1; }
.tn__ch { font-size: 20px; line-height: 1; display: inline-block; }
.tn__ch--swap { animation: tnPop $dur-quick $ease-pop; }
.tn__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); margin: -1px; padding: 0; border: 0; }
@keyframes tnPop { 0% { opacity: .35; transform: scale(.72); } 100% { opacity: 1; transform: scale(1); } }
@media (max-width: 340px) {
.tn { width: 100%; }
.tn__grid { grid-template-columns: repeat(4, 1fr); }
.tn__ch { font-size: 17px; }
}
@media (prefers-reduced-motion: reduce) {
.tn__swatch { transition: none; }
.tn__ch--swap { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tn {
width: min(100%, 320px);
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.tn__strip {
display: flex;
gap: 4px;
}
.tn__swatch {
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
padding: 0;
cursor: pointer;
box-shadow: inset 0 0 0 2px rgba(23, 20, 26, 0.18);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms ease-out;
}
.tn__swatch--t0 {
background: #dccbb8;
}
.tn__swatch--t1 {
background: #ffe0c4;
}
.tn__swatch--t2 {
background: #eae1d5;
}
.tn__swatch--t3 {
background: #d29b6a;
}
.tn__swatch--t4 {
background: #a86f43;
}
.tn__swatch--t5 {
background: #6b4527;
}
.tn__swatch[aria-checked=true] {
transform: scale(1.18);
box-shadow: inset 0 0 0 2px #17141a, 0 0 0 3px #fff7e6;
}
.tn__grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
width: 100%;
}
.tn__item {
display: flex;
align-items: center;
justify-content: center;
background: rgba(23, 20, 26, 0.06);
border-radius: 8px;
padding: 4px;
}
.tn__ch {
font-size: 20px;
line-height: 1;
display: inline-block;
}
.tn__ch--swap {
animation: tnPop 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.tn__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
margin: -1px;
padding: 0;
border: 0;
}
@keyframes tnPop {
0% {
opacity: 0.35;
transform: scale(0.72);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 340px) {
.tn {
width: 100%;
}
.tn__grid {
grid-template-columns: repeat(4, 1fr);
}
.tn__ch {
font-size: 17px;
}
}
@media (prefers-reduced-motion: reduce) {
.tn__swatch {
transition: none;
}
.tn__ch--swap {
animation: none;
}
}(function () {
// 계산 겹: 사람 형태 이모지의 원본 코드포인트에 톤 수정자 코드포인트(U+1F3FB~U+1F3FF)를 이어 붙여
// 새 문자열을 만든다 — 미리 그려둔 다섯 벌을 갈아 끼우는 게 아니라 매번 코드포인트를 조합한다.
var EMOJI_TONE_MODIFIERS = ['\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
var TONE_NAMES = ['Default', 'Tone 1', 'Tone 2', 'Tone 3', 'Tone 4', 'Tone 5'];
var BASES = [
{ cp: '\u{1F44B}', name: 'waving hand' }, { cp: '\u{1F44D}', name: 'thumbs up' },
{ cp: '\u{1F44F}', name: 'clapping hands' }, { cp: '\u{270B}', name: 'raised hand' },
{ cp: '\u{1F64F}', name: 'folded hands' }, { cp: '\u{1F4AA}', name: 'flexed bicep' },
{ cp: '\u{1F44C}', name: 'ok hand' }, { cp: '\u{270C}', name: 'victory hand' },
{ cp: '\u{270D}', name: 'writing hand' }, { cp: '\u{1F64B}', name: 'raising hand' }
];
function combine(base, toneIndex) {
if (toneIndex === 0) return base;
return base + EMOJI_TONE_MODIFIERS[toneIndex - 1];
}
var root = document.getElementById('tn');
var strip = document.getElementById('tnStrip');
var grid = document.getElementById('tnGrid');
var live = document.getElementById('tnLive');
var synthetic = false;
function human() { if (synthetic) return; if (root.classList.contains('is-demo')) root.classList.remove('is-demo'); }
var mounted = false;
var tone = 0;
var swatchEls = [];
function buildStrip() {
TONE_NAMES.forEach(function (label, i) {
var b = document.createElement('button');
b.type = 'button'; b.className = 'tn__swatch tn__swatch--t' + i;
b.setAttribute('role', 'radio'); b.setAttribute('aria-checked', i === 0 ? 'true' : 'false');
b.setAttribute('aria-label', label);
b.addEventListener('click', function () { human(); setTone(i); });
strip.appendChild(b);
swatchEls.push(b);
});
}
function buildGrid() {
BASES.forEach(function (it) {
var li = document.createElement('li');
li.className = 'tn__item'; li.dataset.base = it.cp;
li.innerHTML = '<span class="tn__ch"></span>';
grid.appendChild(li);
});
}
function renderGrid() {
Array.prototype.forEach.call(grid.children, function (li) {
var ch = li.querySelector('.tn__ch');
if (mounted) { ch.classList.remove('tn__ch--swap'); void ch.offsetWidth; ch.classList.add('tn__ch--swap'); }
ch.textContent = combine(li.dataset.base, tone);
});
}
function setTone(i) {
tone = i;
swatchEls.forEach(function (b, j) { b.setAttribute('aria-checked', j === i ? 'true' : 'false'); });
renderGrid();
live.textContent = TONE_NAMES[i] + ' selected, ' + BASES.length + ' updated';
}
buildStrip();
buildGrid();
renderGrid();
mounted = true;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var order = [1, 3, 5, 2, 4, 0];
var oi = 0, loopTimer = 0;
function loop() {
if (!root.classList.contains('is-demo')) return;
synthetic = true; swatchEls[order[oi % order.length]].click(); synthetic = false;
oi++;
loopTimer = window.setTimeout(loop, 620);
}
if (!reduce) { loopTimer = window.setTimeout(loop, 500); }
else { root.classList.remove('is-demo'); }
})();// EmojiSkinToneModifierSwap — vanilla 판(run/402/demo/04-.../index.html) 의 React 포트.
// combine() 은 사람 형태 이모지의 원본 코드포인트에 톤 수정자 코드포인트(U+1F3FB~U+1F3FF)를 그대로
// 이어 붙여 새 문자를 만든다 — 다섯 벌을 미리 그려 바꿔 끼우지 않고 문자 자체를 조합한다.
import { useEffect, useRef, useState } from "react";
import styles from "./EmojiSkinToneModifierSwap.module.scss";
type Box<T> = { current: T };
const EMOJI_TONE_MODIFIERS = ["\u{1F3FB}", "\u{1F3FC}", "\u{1F3FD}", "\u{1F3FE}", "\u{1F3FF}"];
const TONE_NAMES = ["기본", "톤 1", "톤 2", "톤 3", "톤 4", "톤 5"];
const BASES = [
{ cp: "\u{1F44B}", name: "waving hand" }, { cp: "\u{1F44D}", name: "thumbs up" },
{ cp: "\u{1F44F}", name: "clapping hands" }, { cp: "\u{270B}", name: "raised hand" },
{ cp: "\u{1F64F}", name: "folded hands" }, { cp: "\u{1F4AA}", name: "flexed bicep" },
{ cp: "\u{1F44C}", name: "ok hand" }, { cp: "\u{270C}", name: "victory hand" },
{ cp: "\u{270D}", name: "writing hand" }, { cp: "\u{1F64B}", name: "raising hand" },
];
function combine(base: string, toneIndex: number): string {
if (toneIndex === 0) return base;
return base + EMOJI_TONE_MODIFIERS[toneIndex - 1];
}
export default function EmojiSkinToneModifierSwap() {
const [isDemo, setIsDemo] = useState(true);
const [tone, setToneState] = useState(0);
const [swapKey, setSwapKey] = useState(0);
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const synthRef = useRef(false) as unknown as Box<boolean>;
const toneRef = useRef(0) as unknown as Box<number>;
const mountedRef = useRef(false) as unknown as Box<boolean>;
const liveRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const loopTimerRef = useRef(0) as unknown as Box<number>;
const swatchRefs = useRef({} as { [i: number]: HTMLButtonElement }) as unknown as Box<{ [i: number]: HTMLButtonElement }>;
function human() {
if (synthRef.current) return;
if (isDemoRef.current) { isDemoRef.current = false; setIsDemo(false); }
}
function setTone(i: number) {
toneRef.current = i;
setToneState(i);
if (mountedRef.current) setSwapKey((k) => k + 1);
if (liveRef.current) liveRef.current.textContent = TONE_NAMES[i] + " 선택됨, " + BASES.length + "개 갱신";
}
function onSwatchClick(i: number) {
human();
setTone(i);
}
useEffect(() => {
mountedRef.current = true;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const order = [1, 3, 5, 2, 4, 0];
let oi = 0;
function loop() {
if (!isDemoRef.current) return;
const i = order[oi % order.length];
const btn = swatchRefs.current[i];
synthRef.current = true;
if (btn) btn.click(); else onSwatchClick(i);
synthRef.current = false;
oi++;
loopTimerRef.current = window.setTimeout(loop, 620);
}
if (!reduce) { loopTimerRef.current = window.setTimeout(loop, 500); }
else { isDemoRef.current = false; setIsDemo(false); }
return () => window.clearTimeout(loopTimerRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.tn + " " + (isDemo ? styles.isDemo : "")}>
<div className={styles.strip} role="radiogroup" aria-label="톤 선택">
{TONE_NAMES.map((label, i) => (
<button
key={label}
type="button"
className={styles.swatch + " " + (styles as any)["swatchT" + i]}
role="radio"
aria-checked={tone === i}
aria-label={label}
ref={(el: HTMLButtonElement | null) => { if (el) swatchRefs.current[i] = el; }}
onClick={() => onSwatchClick(i)}
/>
))}
</div>
<ul className={styles.grid}>
{BASES.map((it) => (
<li key={it.name} className={styles.item}>
<span key={it.name + "-" + swapKey} className={styles.ch + " " + styles.chSwap}>{combine(it.cp, tone)}</span>
</li>
))}
</ul>
<p className={styles.sr} role="status" aria-live="polite" ref={liveRef} />
</div>
</main>
);
}04 Tone Swap Where a Chosen Tone Value Redraws a Whole Emoji Set — Measured area 3.824% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · Emoji Picker: 9 Patterns by One Value
The post this demo lives in
Emoji Picker: 9 Patterns by One ValueNine emoji picker patterns: The panel flips upward only when the space-below value is smaller than its height, variants See all demos in this post
If you like this effect - similar demos
Inputs06 HSL sliders — a color picker that redraws track gradients whenever the hue value changes9 Color Picker Design Patterns (Copy-Paste)
Inputs06 Keyboard jump — arrow keys change the value by one unit, a modifier key by a larger unitNumber Stepper UI: 9 Interaction Patterns
Inputs01 Length threshold fill — the strength value steps up each time the input's character-count value crosses a set boundary valuePassword Strength Meter UI: 9 Calculation Patterns