01사용 빈도 값으로 순서가 바뀌는 최근 사용 탭
이모지를 고를 때마다 그 이모지의 사용 횟수 값이 하나씩 올라가고, 최근 사용 탭은 그 값이 큰 순서대로 다시 정렬되어 자주 쓰는 이모지가 앞쪽에 남는다.
쓰임채팅 입력창 반응 선택, 커스텀 이모지 보드
RECENT_MAXSEQrenderRecent

<main class="stage">
<div class="ep is-demo" id="ep">
<div class="ep__head">
<span class="ep__label">최근 사용</span>
<span class="ep__sub" id="epSub">순위는 사용 횟수 값이 정한다</span>
</div>
<ul class="ep__recent" id="epRecent" role="listbox" aria-label="최근 사용 이모지, 사용 빈도 순"></ul>
<div class="ep__all" role="listbox" aria-label="전체 이모지" id="epAll"></div>
<p class="ep__sr" role="status" aria-live="polite" id="epLive"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html rank() 가 사용 횟수 값(counts)을 내림차순으로 다시 정렬하고, renderRecent() 가
// FLIP(getBoundingClientRect 실측 → transform 되돌리기)로 그 재배치를 움직임으로 보여준다.
@include stage($stage-ink);
.ep { width: min(100%, 300px); display: flex; flex-direction: column; gap: $sp-2; }
.ep__head { display: flex; justify-content: space-between; align-items: baseline; }
.ep__label { font-size: 12px; font-weight: 700; color: $subject-cream; text-transform: uppercase; letter-spacing: .04em; }
.ep__sub { font-size: 10px; color: rgba($subject-cream, .72); }
.ep__recent {
list-style: none; margin: 0; padding: $sp-1 $sp-2; display: flex; flex-wrap: wrap; gap: $sp-1;
background: rgba($subject-cream, .08); border-radius: $r-control; min-height: 44px;
}
.ep__item {
position: relative; display: flex; flex-direction: column; align-items: center; gap: 0;
width: 36px; padding: $sp-1 0; border-radius: $r-sm; background: rgba($subject-cream, .1);
transition: transform $dur-base $ease-spring;
}
.ep__item--enter { animation: epPop $dur-quick $ease-pop; }
.ep__rank {
position: absolute; top: -4px; right: -2px; width: 14px; height: 14px; border-radius: $r-pill;
background: $subject-blue-deep; color: $subject-cream; font-size: 9px; font-weight: 700;
display: grid; place-items: center;
}
.ep__ch { font-size: 19px; line-height: 1; }
.ep__count { font-size: 9px; color: rgba($subject-cream, .74); }
.ep__all { display: grid; grid-template-columns: repeat(6, 1fr); grid-auto-rows: 26px; gap: $sp-1; }
.ep__all-btn {
border: none; background: rgba($subject-cream, .06); color: $subject-cream; border-radius: $r-sm;
font-size: 15px; line-height: 1; padding: 0; cursor: pointer; display: grid; place-items: center;
}
.ep__all-btn:hover, .ep__all-btn:focus-visible { background: rgba($subject-cream, .18); }
.ep__all-btn:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 1px; }
.ep__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); margin: -1px; padding: 0; border: 0; }
@keyframes epPop { 0% { opacity: 0; transform: scale(.6); } 100% { opacity: 1; transform: scale(1); } }
@media (max-width: 340px) {
.ep { width: 100%; }
.ep__all { grid-template-columns: repeat(5, 1fr); }
}
@media (prefers-reduced-motion: reduce) {
.ep__item { transition: none; }
.ep__item--enter { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ep {
width: min(100%, 300px);
display: flex;
flex-direction: column;
gap: 8px;
}
.ep__head {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.ep__label {
font-size: 12px;
font-weight: 700;
color: #fff7e6;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ep__sub {
font-size: 10px;
color: rgba(255, 247, 230, 0.72);
}
.ep__recent {
list-style: none;
margin: 0;
padding: 4px 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
background: rgba(255, 247, 230, 0.08);
border-radius: 12px;
min-height: 44px;
}
.ep__item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
width: 36px;
padding: 4px 0;
border-radius: 8px;
background: rgba(255, 247, 230, 0.1);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ep__item--enter {
animation: epPop 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ep__rank {
position: absolute;
top: -4px;
right: -2px;
width: 14px;
height: 14px;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-size: 9px;
font-weight: 700;
display: grid;
place-items: center;
}
.ep__ch {
font-size: 19px;
line-height: 1;
}
.ep__count {
font-size: 9px;
color: rgba(255, 247, 230, 0.74);
}
.ep__all {
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-auto-rows: 26px;
gap: 4px;
}
.ep__all-btn {
border: none;
background: rgba(255, 247, 230, 0.06);
color: #fff7e6;
border-radius: 8px;
font-size: 15px;
line-height: 1;
padding: 0;
cursor: pointer;
display: grid;
place-items: center;
}
.ep__all-btn:hover, .ep__all-btn:focus-visible {
background: rgba(255, 247, 230, 0.18);
}
.ep__all-btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 1px;
}
.ep__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
margin: -1px;
padding: 0;
border: 0;
}
@keyframes epPop {
0% {
opacity: 0;
transform: scale(0.6);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 340px) {
.ep {
width: 100%;
}
.ep__all {
grid-template-columns: repeat(5, 1fr);
}
}
@media (prefers-reduced-motion: reduce) {
.ep__item {
transition: none;
}
.ep__item--enter {
animation: none;
}
}(function () {
var EMOJIS = [
{ ch: '\u{1F600}', key: 'smile', seed: 2 }, { ch: '\u{1F602}', key: 'joy', seed: 5 },
{ ch: '\u{1F60D}', key: 'heart-eyes', seed: 1 }, { ch: '\u{1F389}', key: 'party', seed: 4 },
{ ch: '\u{1F525}', key: 'fire', seed: 0 }, { ch: '\u{1F44D}', key: 'thumbs-up', seed: 3 },
{ ch: '\u{1F64F}', key: 'pray', seed: 0 }, { ch: '\u{1F60E}', key: 'cool', seed: 1 },
{ ch: '\u{1F973}', key: 'celebrate', seed: 0 }, { ch: '\u{1F914}', key: 'think', seed: 0 },
{ ch: '\u{1F44F}', key: 'clap', seed: 2 }, { ch: '\u{2764}\u{FE0F}', key: 'love', seed: 0 }
];
var RECENT_MAX = 6;
var counts = {}, lastPicked = {}, seq = 0;
EMOJIS.forEach(function (e) { counts[e.key] = e.seed; });
var recentEl = document.getElementById('epRecent');
var allEl = document.getElementById('epAll');
var live = document.getElementById('epLive');
var root = document.getElementById('ep');
function human() { if (root.classList.contains('is-demo')) root.classList.remove('is-demo'); }
// 계산 겹: 사용 횟수 값 내림차순, 동률이면 가장 최근에 고른 순으로 다시 줄을 세운다
function rank() {
return EMOJIS.slice().sort(function (a, b) {
var d = (counts[b.key] || 0) - (counts[a.key] || 0);
return d !== 0 ? d : (lastPicked[b.key] || 0) - (lastPicked[a.key] || 0);
}).slice(0, RECENT_MAX);
}
function fillItem(li, e, idx) {
li.dataset.key = e.key;
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', 'false');
li.setAttribute('aria-label', e.key + ', 사용 ' + (counts[e.key] || 0) + '회, 순위 ' + (idx + 1));
li.innerHTML = '<b class="ep__rank">' + (idx + 1) + '</b><span class="ep__ch">' + e.ch + '</span><span class="ep__count">' + (counts[e.key] || 0) + '</span>';
}
// 계산 겹: FLIP — 재배치 전 위치를 실측(getBoundingClientRect)해 두고, 재배치 후 그 차이만큼
// transform 으로 되돌린 뒤 transition 으로 0 까지 풀어 "움직여 자리를 바꾸는" 것처럼 보이게 한다.
function renderRecent() {
var before = {};
Array.prototype.forEach.call(recentEl.children, function (li) { before[li.dataset.key] = li.getBoundingClientRect(); });
var list = rank();
recentEl.innerHTML = '';
list.forEach(function (e, idx) {
var li = document.createElement('li');
li.className = 'ep__item';
fillItem(li, e, idx);
li.addEventListener('click', function () { human(); pick(e); });
recentEl.appendChild(li);
});
Array.prototype.forEach.call(recentEl.children, function (li) {
var b = before[li.dataset.key];
if (!b) { li.classList.add('ep__item--enter'); requestAnimationFrame(function () { li.classList.remove('ep__item--enter'); }); return; }
var a = li.getBoundingClientRect(), dx = b.left - a.left, dy = b.top - a.top;
if (Math.abs(dx) > .5 || Math.abs(dy) > .5) {
li.style.transition = 'none';
li.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
li.offsetHeight;
li.style.transition = '';
li.style.transform = '';
}
});
}
function pick(e) {
counts[e.key] = (counts[e.key] || 0) + 1;
lastPicked[e.key] = ++seq;
renderRecent();
live.textContent = e.ch + ' 사용 ' + counts[e.key] + '회 · 최근 사용 순위 갱신';
}
EMOJIS.forEach(function (e) {
var b = document.createElement('button');
b.type = 'button'; b.className = 'ep__all-btn'; b.setAttribute('role', 'option');
b.setAttribute('aria-selected', 'false'); b.setAttribute('aria-label', e.key);
b.textContent = e.ch;
b.addEventListener('click', function () { human(); pick(e); });
allEl.appendChild(b);
});
renderRecent();
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var SEQ = [4, 6, 9, 4, 8, 6];
var i = 0, timer = 0;
function loop() {
if (!root.classList.contains('is-demo')) return;
pick(EMOJIS[SEQ[i % SEQ.length]]);
i++;
timer = window.setTimeout(loop, 420);
}
if (!reduce) { timer = window.setTimeout(loop, 380); }
else { root.classList.remove('is-demo'); }
})();// EmojiRecentFrequencyRank — vanilla 판(run/402/demo/01-emoji-picker-recent-frequency-rank/index.html) 의 React 포트.
// rank() 가 사용 횟수 값을 내림차순으로 정렬하고, useEffect 가 재배치 전 위치를 실측(getBoundingClientRect)해
// FLIP 으로 그 차이만큼 transform 되돌린 뒤 CSS transition 으로 0 까지 풀어 움직임을 만든다.
import { useEffect, useRef, useState } from "react";
import styles from "./EmojiRecentFrequencyRank.module.scss";
type Box<T> = { current: T };
type Emoji = { ch: string; key: string; seed: number };
const EMOJIS: Emoji[] = [
{ ch: "\u{1F600}", key: "smile", seed: 2 }, { ch: "\u{1F602}", key: "joy", seed: 5 },
{ ch: "\u{1F60D}", key: "heart-eyes", seed: 1 }, { ch: "\u{1F389}", key: "party", seed: 4 },
{ ch: "\u{1F525}", key: "fire", seed: 0 }, { ch: "\u{1F44D}", key: "thumbs-up", seed: 3 },
{ ch: "\u{1F64F}", key: "pray", seed: 0 }, { ch: "\u{1F60E}", key: "cool", seed: 1 },
{ ch: "\u{1F973}", key: "celebrate", seed: 0 }, { ch: "\u{1F914}", key: "think", seed: 0 },
{ ch: "\u{1F44F}", key: "clap", seed: 2 }, { ch: "\u{2764}\u{FE0F}", key: "love", seed: 0 },
];
const RECENT_MAX = 6;
const SEQ = [4, 6, 9, 4, 8, 6];
export default function EmojiRecentFrequencyRank() {
const countsRef = useRef({} as Record<string, number>) as unknown as Box<Record<string, number>>;
const lastPickedRef = useRef({} as Record<string, number>) as unknown as Box<Record<string, number>>;
const seqRef = useRef(0) as unknown as Box<number>;
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const timerRef = useRef(0) as unknown as Box<number>;
const recentRef = useRef(null) as unknown as Box<HTMLUListElement>;
const rectsRef = useRef({} as Record<string, DOMRect>) as unknown as Box<Record<string, DOMRect>>;
if (Object.keys(countsRef.current).length === 0) {
EMOJIS.forEach((e) => { countsRef.current[e.key] = e.seed; });
}
const [tick, setTick] = useState(0);
const [isDemo, setIsDemo] = useState(true);
const [live, setLive] = useState("");
function rank(): Emoji[] {
const c = countsRef.current, lp = lastPickedRef.current;
return EMOJIS.slice().sort((a, b) => {
const d = (c[b.key] || 0) - (c[a.key] || 0);
return d !== 0 ? d : (lp[b.key] || 0) - (lp[a.key] || 0);
}).slice(0, RECENT_MAX);
}
function human() { if (isDemoRef.current) { isDemoRef.current = false; setIsDemo(false); } }
function pick(e: Emoji) {
if (recentRef.current) {
rectsRef.current = {};
Array.prototype.forEach.call(recentRef.current.children, (li: HTMLElement) => {
rectsRef.current[li.dataset.key as string] = li.getBoundingClientRect();
});
}
countsRef.current[e.key] = (countsRef.current[e.key] || 0) + 1;
lastPickedRef.current[e.key] = ++seqRef.current;
setLive(e.ch + " 사용 " + countsRef.current[e.key] + "회 · 최근 사용 순위 갱신");
setTick((t) => t + 1);
}
useEffect(() => {
if (!recentRef.current) return;
const before = rectsRef.current;
Array.prototype.forEach.call(recentRef.current.children, (li: HTMLElement) => {
const key = li.dataset.key as string, b = before[key];
if (!b) { li.classList.add(styles.itemEnter); requestAnimationFrame(() => li.classList.remove(styles.itemEnter)); return; }
const a = li.getBoundingClientRect(), dx = b.left - a.left, dy = b.top - a.top;
if (Math.abs(dx) > .5 || Math.abs(dy) > .5) {
li.style.transition = "none";
li.style.transform = "translate(" + dx + "px," + dy + "px)";
li.offsetHeight;
li.style.transition = "";
li.style.transform = "";
}
});
}, [tick]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let i = 0;
function loop() {
if (!isDemoRef.current) return;
pick(EMOJIS[SEQ[i % SEQ.length]]);
i++;
timerRef.current = window.setTimeout(loop, 420);
}
if (!reduce) { timerRef.current = window.setTimeout(loop, 380); }
else { isDemoRef.current = false; setIsDemo(false); }
return () => window.clearTimeout(timerRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const list = rank();
return (
<main className={styles.stage}>
<div className={styles.ep + " " + (isDemo ? styles.isDemo : "")}>
<div className={styles.head}>
<span className={styles.label}>최근 사용</span>
<span className={styles.sub}>순위는 사용 횟수 값이 정한다</span>
</div>
<ul className={styles.recent} role="listbox" aria-label="최근 사용 이모지, 사용 빈도 순" ref={recentRef}>
{list.map((e, idx) => (
<li key={e.key} data-key={e.key} className={styles.item} role="option" aria-selected="false"
aria-label={e.key + ", 사용 " + (countsRef.current[e.key] || 0) + "회, 순위 " + (idx + 1)}
onClick={() => { human(); pick(e); }}>
<b className={styles.rank}>{idx + 1}</b>
<span className={styles.ch}>{e.ch}</span>
<span className={styles.count}>{countsRef.current[e.key] || 0}</span>
</li>
))}
</ul>
<div className={styles.all} role="listbox" aria-label="전체 이모지">
{EMOJIS.map((e) => (
<button key={e.key} type="button" className={styles.allBtn} role="option" aria-selected="false"
aria-label={e.key} onClick={() => { human(); pick(e); }}>{e.ch}</button>
))}
</div>
<p className={styles.sr} role="status" aria-live="polite">{live}</p>
</div>
</main>
);
}01 사용 빈도 값으로 순서가 바뀌는 최근 사용 탭 — 측정 누적면적 2.785% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 이모지 검색기 9가지 — 값 하나로 여닫는 패턴
이 데모가 속한 글
이모지 검색기 9가지 — 값 하나로 여닫는 패턴이모지 검색기 아홉 가지를 나란히 놓고 보면, 입력창 아래 남은 공간 값이 패널 높이보다 작을 때만 패널이 위로 열리는 자리로 바뀌고, 누르고 있는 시간 값이 정해둔 값을 넘을 때만 같은 이모지의 변형이 부채꼴로 펼 이 글의 데모 전부 보기


