05押し続けた時間の値がしきい値を超えるとバリエーションが広がる長押し
指を離すまで押し続けた時間の値を計り続け、決めた値を超えた瞬間に同じ絵文字の色や表情違いのバリエーションが扇状に広がり、それより早く離すと元の一つだけがそのまま選ばれる。
使いどころ肌色・表情のバリエーションがあるリアクション絵文字
EMOJI_LONGPRESS_THRESHOLD_MStickopenFan

<main class="stage">
<div class="lp is-demo" id="lp">
<div class="lp__wrap">
<div class="lp__fan" id="lpFan" role="menu" aria-label="リアクションのバリエーション" hidden></div>
<button class="lp__main" id="lpMain" type="button" aria-label="いいね">
<span class="lp__mainCh" id="lpMainCh">👍</span>
<span class="lp__ring" aria-hidden="true"><span class="lp__ringFill" id="lpRingFill"></span></span>
</button>
</div>
<p class="lp__sr" role="status" aria-live="polite" id="lpLive"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html press()/tick() 이 pointerdown 시각을 performance.now() 로 저장해 두고
// 매 프레임 지금 시각과의 차(누른 시간 값)를 문턱과 비교한다 — 그 값을 링 채움 비율로 그대로 쓴다.
@include stage($stage-ink);
.lp { display: flex; flex-direction: column; align-items: center; gap: $sp-2; }
.lp__wrap { position: relative; display: flex; flex-direction: column; align-items: center; }
.lp__main {
position: relative; width: 56px; height: 56px; border-radius: 50%; border: none;
background: rgba($subject-cream, .92); display: flex; align-items: center; justify-content: center;
box-shadow: $shadow-raised; cursor: pointer; touch-action: none;
}
.lp__mainCh { font-size: 26px; line-height: 1; display: inline-block; }
.lp__mainCh--pop { animation: lpPop $dur-quick $ease-pop; }
.lp__ring { position: absolute; left: 8px; right: 8px; bottom: -10px; height: 4px; border-radius: $r-xs; background: rgba($subject-cream, .28); overflow: hidden; }
.lp__ringFill { display: block; width: 100%; height: 100%; background: $subject-cream; transform: scaleX(0); transform-origin: left center; }
// 부채꼴: 각 변형은 --i 로 받은 자기 순번만큼 회전해 자리를 잡고, 다시 반대로 회전해 글자를 세운다
// (rotate → translateY → rotate). transform·opacity 만 쓰므로 위치가 바뀌어도 레이아웃은 안 움직인다.
.lp__fan {
position: absolute; left: 50%; bottom: 100%; margin-bottom: $sp-2; width: 0; height: 0; list-style: none;
}
.lp__variant {
position: absolute; left: 0; top: 0; width: 40px; height: 40px; margin: -20px 0 0 -20px; border: none; border-radius: 50%;
background: rgba($subject-cream, .92); display: flex; align-items: center; justify-content: center; font-size: 18px;
box-shadow: $shadow-press; cursor: pointer; opacity: 0;
transform: rotate(calc((var(--i) - 2) * 20deg)) translateY(0) rotate(calc((var(--i) - 2) * -20deg)) scale(.5);
transition: transform $dur-base $ease-spring, opacity $dur-quick $ease-out, box-shadow $dur-quick $ease-out;
transition-delay: calc(var(--i) * 18ms);
}
.lp__fan--open .lp__variant {
opacity: 1;
transform: rotate(calc((var(--i) - 2) * 20deg)) translateY(-58px) rotate(calc((var(--i) - 2) * -20deg)) scale(1);
}
.lp__variant--hover { box-shadow: 0 0 0 3px $subject-cream, $shadow-raised; transform: rotate(calc((var(--i) - 2) * 20deg)) translateY(-64px) rotate(calc((var(--i) - 2) * -20deg)) scale(1.25) !important; }
.lp__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); margin: -1px; padding: 0; border: 0; }
@keyframes lpPop { 0% { transform: scale(.6); } 60% { transform: scale(1.22); } 100% { transform: scale(1); } }
@media (max-width: 340px) {
.lp__main { width: 48px; height: 48px; }
.lp__mainCh { font-size: 22px; }
.lp__variant { width: 34px; height: 34px; margin: -17px 0 0 -17px; font-size: 15px; }
.lp__fan--open .lp__variant { transform: rotate(calc((var(--i) - 2) * 20deg)) translateY(-46px) rotate(calc((var(--i) - 2) * -20deg)) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.lp__mainCh--pop { animation: none; }
.lp__ringFill { transition: none; }
.lp__variant { transition: none; transition-delay: 0ms; }
}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);
}
.lp {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.lp__wrap {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.lp__main {
position: relative;
width: 56px;
height: 56px;
border-radius: 50%;
border: none;
background: rgba(255, 247, 230, 0.92);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: pointer;
touch-action: none;
}
.lp__mainCh {
font-size: 26px;
line-height: 1;
display: inline-block;
}
.lp__mainCh--pop {
animation: lpPop 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.lp__ring {
position: absolute;
left: 8px;
right: 8px;
bottom: -10px;
height: 4px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.28);
overflow: hidden;
}
.lp__ringFill {
display: block;
width: 100%;
height: 100%;
background: #fff7e6;
transform: scaleX(0);
transform-origin: left center;
}
.lp__fan {
position: absolute;
left: 50%;
bottom: 100%;
margin-bottom: 8px;
width: 0;
height: 0;
list-style: none;
}
.lp__variant {
position: absolute;
left: 0;
top: 0;
width: 40px;
height: 40px;
margin: -20px 0 0 -20px;
border: none;
border-radius: 50%;
background: rgba(255, 247, 230, 0.92);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: pointer;
opacity: 0;
transform: rotate(calc((var(--i) - 2) * 20deg)) translateY(0) rotate(calc((var(--i) - 2) * -20deg)) scale(0.5);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms ease-out, box-shadow 220ms ease-out;
transition-delay: calc(var(--i) * 18ms);
}
.lp__fan--open .lp__variant {
opacity: 1;
transform: rotate(calc((var(--i) - 2) * 20deg)) translateY(-58px) rotate(calc((var(--i) - 2) * -20deg)) scale(1);
}
.lp__variant--hover {
box-shadow: 0 0 0 3px #fff7e6, 0 14px 24px rgba(23, 20, 26, 0.32);
transform: rotate(calc((var(--i) - 2) * 20deg)) translateY(-64px) rotate(calc((var(--i) - 2) * -20deg)) scale(1.25) !important;
}
.lp__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
margin: -1px;
padding: 0;
border: 0;
}
@keyframes lpPop {
0% {
transform: scale(0.6);
}
60% {
transform: scale(1.22);
}
100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.lp__main {
width: 48px;
height: 48px;
}
.lp__mainCh {
font-size: 22px;
}
.lp__variant {
width: 34px;
height: 34px;
margin: -17px 0 0 -17px;
font-size: 15px;
}
.lp__fan--open .lp__variant {
transform: rotate(calc((var(--i) - 2) * 20deg)) translateY(-46px) rotate(calc((var(--i) - 2) * -20deg)) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.lp__mainCh--pop {
animation: none;
}
.lp__ringFill {
transition: none;
}
.lp__variant {
transition: none;
transition-delay: 0ms;
}
}(function () {
var EMOJI_LONGPRESS_THRESHOLD_MS = 550;
var VARIANTS = [
{ ch: '\u{1F44D}', label: 'いいね' }, { ch: '\u{2764}\u{FE0F}', label: '大好き' },
{ ch: '\u{1F602}', label: '笑える' }, { ch: '\u{1F62E}', label: 'びっくり' },
{ ch: '\u{1F622}', label: '悲しい' }
];
var root = document.getElementById('lp');
var main = document.getElementById('lpMain');
var mainCh = document.getElementById('lpMainCh');
var ringFill = document.getElementById('lpRingFill');
var fan = document.getElementById('lpFan');
var live = document.getElementById('lpLive');
var synthetic = false;
function human() { if (synthetic) return; if (root.classList.contains('is-demo')) root.classList.remove('is-demo'); }
var variantEls = [];
VARIANTS.forEach(function (v, i) {
var b = document.createElement('button');
b.type = 'button'; b.className = 'lp__variant'; b.dataset.i = String(i);
b.setAttribute('role', 'menuitem'); b.setAttribute('aria-label', v.label);
b.style.setProperty('--i', String(i));
b.textContent = v.ch;
fan.appendChild(b);
variantEls.push(b);
});
// 계산 겹: pointerdown 시각을 performance.now() 로 저장해 두고 매 프레임 지금 시각과의 차(누른
// 시간 값)를 문턱과 비교한다 — 그 값을 그대로 링 채움 비율로 써서 문턱까지 남은 거리를 보여주고,
// 값이 문턱을 넘는 순간에만 변형이 펼쳐진다.
var pressing = false, pressStart = 0, fanOpened = false, rafId = 0, hoverIndex = -1;
function tick() {
if (!pressing) return;
var elapsed = performance.now() - pressStart;
var pct = Math.min(1, elapsed / EMOJI_LONGPRESS_THRESHOLD_MS);
ringFill.style.transform = 'scaleX(' + pct + ')';
if (pct >= 1 && !fanOpened) openFan();
rafId = requestAnimationFrame(tick);
}
function openFan() {
fanOpened = true;
fan.hidden = false;
requestAnimationFrame(function () { fan.classList.add('lp__fan--open'); });
live.textContent = 'バリエーション ' + VARIANTS.length + '個展開';
}
function closeFan() {
fan.classList.remove('lp__fan--open');
variantEls.forEach(function (b) { b.classList.remove('lp__variant--hover'); });
hoverIndex = -1;
window.setTimeout(function () { if (!fanOpened) fan.hidden = true; }, 220);
}
function selectDefault() {
mainCh.classList.remove('lp__mainCh--pop'); void mainCh.offsetWidth; mainCh.classList.add('lp__mainCh--pop');
live.textContent = VARIANTS[0].label + ' 選択';
}
function selectVariant(i) {
mainCh.textContent = VARIANTS[i].ch;
mainCh.classList.remove('lp__mainCh--pop'); void mainCh.offsetWidth; mainCh.classList.add('lp__mainCh--pop');
live.textContent = VARIANTS[i].label + ' 選択';
}
function setHover(i) {
if (hoverIndex === i) return;
if (hoverIndex >= 0) variantEls[hoverIndex].classList.remove('lp__variant--hover');
hoverIndex = i;
if (i >= 0) variantEls[i].classList.add('lp__variant--hover');
}
function press() {
pressing = true; pressStart = performance.now(); fanOpened = false; hoverIndex = -1;
ringFill.style.transform = 'scaleX(0)';
rafId = requestAnimationFrame(tick);
}
function release() {
if (!pressing) return;
pressing = false;
cancelAnimationFrame(rafId);
ringFill.style.transform = 'scaleX(0)';
if (fanOpened) {
if (hoverIndex >= 0) selectVariant(hoverIndex); else live.textContent = '閉じた';
closeFan();
} else {
selectDefault();
}
}
main.addEventListener('pointerdown', function () { human(); press(); });
window.addEventListener('pointerup', function () { release(); });
main.addEventListener('pointercancel', function () { release(); });
var mounted = false;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var steps = [
{ hold: 240, hover: -1 },
{ hold: 780, hover: 1 },
{ hold: 240, hover: -1 },
{ hold: 900, hover: 3 }
];
var si = 0, loopTimer = 0;
function runStep() {
if (!root.classList.contains('is-demo')) return;
var s = steps[si % steps.length];
synthetic = true; main.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1 })); synthetic = false;
var mid = Math.round(s.hold * .78);
window.setTimeout(function () {
if (s.hover >= 0 && fanOpened) setHover(s.hover);
}, mid);
window.setTimeout(function () {
synthetic = true; window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1 })); synthetic = false;
si++;
loopTimer = window.setTimeout(runStep, 520);
}, s.hold);
}
if (!reduce) { loopTimer = window.setTimeout(runStep, 400); }
else { root.classList.remove('is-demo'); }
mounted = true;
})();// EmojiPickerVariantLongpressFan — vanilla 판(run/402/demo/05-.../index.html) 의 React 포트.
// 계산 겹: pointerdown 시각을 performance.now() 로 저장해 두고 매 프레임 지금 시각과의 차(누른
// 시간 값)를 문턱과 비교한다 — 그 값을 그대로 링 채움 비율로 써서, 값이 문턱을 넘는 순간에만
// 반응 변형이 부채꼴로 펼쳐진다.
import { useEffect, useRef, useState } from "react";
import styles from "./EmojiPickerVariantLongpressFan.module.scss";
type Box<T> = { current: T };
const EMOJI_LONGPRESS_THRESHOLD_MS = 550;
const VARIANTS = [
{ ch: "\u{1F44D}", label: "좋아요" },
{ ch: "\u{2764}\u{FE0F}", label: "사랑해요" },
{ ch: "\u{1F602}", label: "웃겨요" },
{ ch: "\u{1F62E}", label: "놀라워요" },
{ ch: "\u{1F622}", label: "슬퍼요" },
];
export default function EmojiPickerVariantLongpressFan() {
const [isDemo, setIsDemo] = useState(true);
const [fanOpen, setFanOpen] = useState(false);
const [hoverIndex, setHoverIndex] = useState(-1);
const [ch, setCh] = useState(VARIANTS[0].ch);
const [pop, setPop] = useState(0);
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const synthRef = useRef(false) as unknown as Box<boolean>;
const pressingRef = useRef(false) as unknown as Box<boolean>;
const pressStartRef = useRef(0) as unknown as Box<number>;
const fanOpenedRef = useRef(false) as unknown as Box<boolean>;
const hoverIndexRef = useRef(-1) as unknown as Box<number>;
const rafRef = useRef(0) as unknown as Box<number>;
const loopTimerRef = useRef(0) as unknown as Box<number>;
const liveRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const ringRef = useRef(null) as unknown as Box<HTMLSpanElement>;
function human() {
if (synthRef.current) return;
if (isDemoRef.current) { isDemoRef.current = false; setIsDemo(false); }
}
function tick() {
if (!pressingRef.current) return;
const elapsed = performance.now() - pressStartRef.current;
const pct = Math.min(1, elapsed / EMOJI_LONGPRESS_THRESHOLD_MS);
if (ringRef.current) ringRef.current.style.transform = "scaleX(" + pct + ")";
if (pct >= 1 && !fanOpenedRef.current) openFan();
rafRef.current = requestAnimationFrame(tick);
}
function openFan() {
fanOpenedRef.current = true;
setFanOpen(true);
if (liveRef.current) liveRef.current.textContent = "변형 " + VARIANTS.length + "개 펼쳐짐";
}
function closeFan(commit: boolean) {
setFanOpen(false);
setHoverIndex(-1);
hoverIndexRef.current = -1;
if (!commit && liveRef.current) liveRef.current.textContent = "닫힘";
fanOpenedRef.current = false;
}
function selectDefault() {
setCh(VARIANTS[0].ch);
setPop((p) => p + 1);
if (liveRef.current) liveRef.current.textContent = VARIANTS[0].label + " 선택됨";
}
function selectVariant(i: number) {
setCh(VARIANTS[i].ch);
setPop((p) => p + 1);
if (liveRef.current) liveRef.current.textContent = VARIANTS[i].label + " 선택됨";
}
function setHover(i: number) {
if (hoverIndexRef.current === i) return;
hoverIndexRef.current = i;
setHoverIndex(i);
}
function press() {
pressingRef.current = true;
pressStartRef.current = performance.now();
fanOpenedRef.current = false;
hoverIndexRef.current = -1;
setHoverIndex(-1);
if (ringRef.current) ringRef.current.style.transform = "scaleX(0)";
rafRef.current = requestAnimationFrame(tick);
}
function release() {
if (!pressingRef.current) return;
pressingRef.current = false;
cancelAnimationFrame(rafRef.current);
if (ringRef.current) ringRef.current.style.transform = "scaleX(0)";
if (fanOpenedRef.current) {
const hi = hoverIndexRef.current;
if (hi >= 0) selectVariant(hi);
closeFan(hi >= 0);
} else {
selectDefault();
}
}
useEffect(() => {
function onUp() { release(); }
window.addEventListener("pointerup", onUp);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const steps = [
{ hold: 240, hover: -1 },
{ hold: 780, hover: 1 },
{ hold: 240, hover: -1 },
{ hold: 900, hover: 3 },
];
let si = 0;
function runStep() {
if (!isDemoRef.current) return;
const s = steps[si % steps.length];
synthRef.current = true; press(); synthRef.current = false;
const mid = Math.round(s.hold * 0.78);
window.setTimeout(() => { if (s.hover >= 0 && fanOpenedRef.current) setHover(s.hover); }, mid);
window.setTimeout(() => {
synthRef.current = true; release(); synthRef.current = false;
si++;
loopTimerRef.current = window.setTimeout(runStep, 520);
}, s.hold);
}
if (!reduce) { loopTimerRef.current = window.setTimeout(runStep, 400); }
else { isDemoRef.current = false; setIsDemo(false); }
return () => { window.removeEventListener("pointerup", onUp); window.clearTimeout(loopTimerRef.current); cancelAnimationFrame(rafRef.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.lp + " " + (isDemo ? styles.isDemo : "")}>
<div className={styles.wrap}>
<div
className={styles.fan + " " + (fanOpen ? styles.fanOpen : "")}
role="menu"
aria-label="반응 변형"
hidden={!fanOpen && !isDemo}
>
{VARIANTS.map((v, i) => (
<button
key={v.label}
type="button"
className={styles.variant + " " + (hoverIndex === i ? styles.variantHover : "")}
role="menuitem"
aria-label={v.label}
style={{ "--i": i } as any}
>
{v.ch}
</button>
))}
</div>
<button
className={styles.main}
type="button"
aria-label="좋아요"
onPointerDown={() => { human(); press(); }}
onPointerCancel={() => release()}
>
<span key={pop} className={styles.mainCh + " " + styles.mainChPop}>{ch}</span>
<span className={styles.ring} aria-hidden="true">
<span className={styles.ringFill} ref={ringRef} />
</span>
</button>
</div>
<p className={styles.sr} role="status" aria-live="polite" ref={liveRef} />
</div>
</main>
);
}05 押し続けた時間の値がしきい値を超えるとバリエーションが広がる長押し — 計測 累積面積 5.267% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · 絵文字ピッカー 9パターン — 値で開閉
このデモが属する記事
絵文字ピッカー 9パターン — 値で開閉入力欄の下に残る空間の値がパネルの高さより小さいときだけパネルは上に開く位置に変わり、押し続けた時間の値が決めたしきい値を超えたときだけ同じ絵文字のバリエーションが扇状に広がり、カーソル位置の値をあらかじめ保存していた場合だけ選んだ絵文字が この記事のデモをすべて見る


