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 オリジナル · SCSS と React は zip に同梱 · 絵文字ピッカー 9パターン — 値で開閉
このデモが属する記事
絵文字ピッカー 9パターン — 値で開閉入力欄の下に残る空間の値がパネルの高さより小さいときだけパネルは上に開く位置に変わり、押し続けた時間の値が決めたしきい値を超えたときだけ同じ絵文字のバリエーションが扇状に広がり、カーソル位置の値をあらかじめ保存していた場合だけ選んだ絵文字が この記事のデモをすべて見る


