04고른 톤 값에 따라 해당 이모지 집합이 통째로 바뀌는 톤 전환
톤 선택 칸에서 고른 톤 값을 저장해 두었다가, 사람 형태를 가진 이모지가 화면에 그려질 때마다 그 값을 같이 읽어 손·얼굴이 있는 부분만 고른 톤으로 바뀐 버전으로 대신 그린다.
쓰임손 모양·얼굴 이모지가 많은 반응 보드
EMOJI_TONE_MODIFIERScombineBASES

<main class="stage">
<div class="tn is-demo" id="tn">
<div class="tn__strip" role="radiogroup" aria-label="톤 선택" 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 = ['기본', '톤 1', '톤 2', '톤 3', '톤 4', '톤 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] + ' 선택됨, ' + BASES.length + '개 갱신';
}
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 고른 톤 값에 따라 해당 이모지 집합이 통째로 바뀌는 톤 전환 — 측정 누적면적 3.824% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 이모지 검색기 9가지 — 값 하나로 여닫는 패턴
이 데모가 속한 글
이모지 검색기 9가지 — 값 하나로 여닫는 패턴이모지 검색기 아홉 가지를 나란히 놓고 보면, 입력창 아래 남은 공간 값이 패널 높이보다 작을 때만 패널이 위로 열리는 자리로 바뀌고, 누르고 있는 시간 값이 정해둔 값을 넘을 때만 같은 이모지의 변형이 부채꼴로 펼 이 글의 데모 전부 보기


