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 원작 · zip 에 SCSS·React 포함 · 이모지 검색기 9가지 — 값 하나로 여닫는 패턴
이 데모가 속한 글
이모지 검색기 9가지 — 값 하나로 여닫는 패턴이모지 검색기 아홉 가지를 나란히 놓고 보면, 입력창 아래 남은 공간 값이 패널 높이보다 작을 때만 패널이 위로 열리는 자리로 바뀌고, 누르고 있는 시간 값이 정해둔 값을 넘을 때만 같은 이모지의 변형이 부채꼴로 펼 이 글의 데모 전부 보기


