05롱프레스 액션 메뉴
리스트 행을 0.5초 이상 누르고 있으면 즐겨찾기·보관·삭제 아이콘 메뉴가 커지며 튀어 오르듯 나타난다.
쓰임채팅 메시지 길게 눌러 반응 메뉴, 파일 리스트 빠른 작업
pointerdown 타이머 500msscale bounce keyframes키보드용 더보기 버튼 대체

<main class="stage">
<div class="lp-row is-demo">
<span class="lp-row__title">디자인 회의 메모</span>
<button class="lp-row__more" type="button" aria-haspopup="true" aria-expanded="false" aria-label="작업 메뉴 열기">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M176 32v192a16 16 0 0 1-16 16H96a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h64a16 16 0 0 1 16 16" opacity=".2"/><path d="M140 128a12 12 0 1 1-12-12a12 12 0 0 1 12 12m-12-56a12 12 0 1 0-12-12a12 12 0 0 0 12 12m0 112a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg>
</button>
<div class="lp-menu" role="menu">
<button class="lp-menu__item" type="button" role="menuitem" aria-label="즐겨찾기">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg>
</button>
<button class="lp-menu__item" type="button" role="menuitem" aria-label="보관">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 96v96a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96Z" opacity=".2"/><path d="M224 48H32a16 16 0 0 0-16 16v24a16 16 0 0 0 16 16v88a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16v-88a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16m-16 144H48v-88h160Zm16-104H32V64h192zM96 136a8 8 0 0 1 8-8h48a8 8 0 0 1 0 16h-48a8 8 0 0 1-8-8"/></g></svg>
</button>
<button class="lp-menu__item lp-menu__item--danger" type="button" role="menuitem" aria-label="삭제">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 메뉴 확대 시간
$easing: $ease-pop;
$color: $stage-ink;
@include stage($stage-ink);
.lp-row {
position: relative;
width: min(240px, 78vw);
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-4;
border-radius: $r-card;
background: $stage-paper;
touch-action: manipulation;
}
.lp-row__title { font-size: 14px; font-weight: 700; color: $color; }
.lp-row__more {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .06);
color: $color;
cursor: pointer;
}
.lp-row__more svg { width: 16px; height: 16px; }
.lp-row__more:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.lp-menu {
position: absolute;
top: 100%;
right: $sp-4;
margin-top: $sp-2;
display: flex;
gap: $sp-2;
padding: $sp-2;
border-radius: $r-control;
background: $color;
box-shadow: $shadow-raised;
transform: scale(.6);
opacity: 0;
transform-origin: top right; // 규칙8: 우상단 참조 변에서 펼침
transition: transform $duration $easing, opacity $duration $easing;
pointer-events: none;
}
.lp-row.is-open .lp-menu { transform: scale(1); opacity: 1; pointer-events: auto; }
.lp-menu__item {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: $r-sm;
background: rgba(255, 255, 255, .08);
color: $stage-paper;
cursor: pointer;
}
.lp-menu__item svg { width: 16px; height: 16px; }
.lp-menu__item--danger { color: $error; }
.lp-menu__item:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
// 데모 강제 루프 — 메뉴가 튀어 오르듯 나타났다 사라짐을 keyframes 로
.lp-row.is-demo .lp-menu { animation: lp-loop $dur-loop $easing infinite; }
@keyframes lp-loop {
0%, 10% { transform: scale(.6); opacity: 0; }
35% { transform: scale(1.06); opacity: 1; }
45%, 70% { transform: scale(1); opacity: 1; }
90%, 100% { transform: scale(.6); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.lp-menu { transition: 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);
}
.lp-row {
position: relative;
width: min(240px, 78vw);
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-radius: 16px;
background: #ffffff;
touch-action: manipulation;
}
.lp-row__title {
font-size: 14px;
font-weight: 700;
color: #17141a;
}
.lp-row__more {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.06);
color: #17141a;
cursor: pointer;
}
.lp-row__more svg {
width: 16px;
height: 16px;
}
.lp-row__more:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.lp-menu {
position: absolute;
top: 100%;
right: 16px;
margin-top: 8px;
display: flex;
gap: 8px;
padding: 8px;
border-radius: 12px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: scale(0.6);
opacity: 0;
transform-origin: top right;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
pointer-events: none;
}
.lp-row.is-open .lp-menu {
transform: scale(1);
opacity: 1;
pointer-events: auto;
}
.lp-menu__item {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
cursor: pointer;
}
.lp-menu__item svg {
width: 16px;
height: 16px;
}
.lp-menu__item--danger {
color: #c2361a;
}
.lp-menu__item:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.lp-row.is-demo .lp-menu {
animation: lp-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes lp-loop {
0%, 10% {
transform: scale(0.6);
opacity: 0;
}
35% {
transform: scale(1.06);
opacity: 1;
}
45%, 70% {
transform: scale(1);
opacity: 1;
}
90%, 100% {
transform: scale(0.6);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.lp-menu {
transition: none;
}
}(function () {
var row = document.querySelector('.lp-row');
var more = row.querySelector('.lp-row__more');
var timer = null;
function openMenu() {
row.classList.remove('is-demo');
row.classList.add('is-open');
more.setAttribute('aria-expanded', 'true');
}
function closeMenu() {
row.classList.remove('is-open');
more.setAttribute('aria-expanded', 'false');
}
row.addEventListener('pointerdown', function (e) {
if (e.target.closest('button')) return;
timer = setTimeout(openMenu, 500);
});
['pointerup', 'pointerleave', 'pointercancel'].forEach(function (ev) {
row.addEventListener(ev, function () { clearTimeout(timer); });
});
more.addEventListener('click', function () {
row.classList.contains('is-open') ? closeMenu() : openMenu();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && row.classList.contains('is-open')) { closeMenu(); more.focus({ preventScroll: true }); }
});
})();import { useRef, useState, type CSSProperties, type PointerEvent } from "react";
import styles from "./LongPressActionsMenu.module.scss";
export type LongPressActionsMenuProps = {
title: string;
/** 메뉴 확대 시간 (기본 220ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const HOLD_MS = 500;
/** pointerdown 후 500ms 유지하면 메뉴가 열린다. "..." 버튼은 길게 누르기 없이도 같은 메뉴를 여는 키보드 대체 수단 */
export default function LongPressActionsMenu({
title,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#17141a",
}: LongPressActionsMenuProps) {
const [open, setOpen] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const moreRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const down = (e: PointerEvent<HTMLDivElement>) => {
if ((e.target as HTMLElement).closest("button")) return;
timer.current = setTimeout(() => setOpen(true), HOLD_MS);
};
const cancel = () => { if (timer.current) clearTimeout(timer.current); };
return (
<div
className={styles.row}
style={vars}
onPointerDown={down}
onPointerUp={cancel}
onPointerLeave={cancel}
onPointerCancel={cancel}
onKeyDown={(e: any) => { if (e.key === "Escape" && open) { setOpen(false); moreRef.current?.focus(); } }}
>
<span className={styles.title}>{title}</span>
<button ref={moreRef} className={styles.more} type="button" aria-haspopup="true" aria-expanded={open} aria-label="작업 메뉴 열기" onClick={() => setOpen((v) => !v)}>
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M176 32v192a16 16 0 0 1-16 16H96a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h64a16 16 0 0 1 16 16" opacity=".2" /><path d="M140 128a12 12 0 1 1-12-12a12 12 0 0 1 12 12m-12-56a12 12 0 1 0-12-12a12 12 0 0 0 12 12m0 112a12 12 0 1 0 12 12a12 12 0 0 0-12-12" /></g></svg>
</button>
<div className={`${styles.menu} ${open ? styles.open : ""}`} role="menu">
<button className={styles.item} type="button" role="menuitem" aria-label="즐겨찾기">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2" /><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5" /></g></svg>
</button>
<button className={styles.item} type="button" role="menuitem" aria-label="보관">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 96v96a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96Z" opacity=".2" /><path d="M224 48H32a16 16 0 0 0-16 16v24a16 16 0 0 0 16 16v88a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16v-88a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16m-16 144H48v-88h160Zm16-104H32V64h192zM96 136a8 8 0 0 1 8-8h48a8 8 0 0 1 0 16h-48a8 8 0 0 1-8-8" /></g></svg>
</button>
<button className={`${styles.item} ${styles.danger}`} type="button" role="menuitem" aria-label="삭제">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2" /><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0" /></g></svg>
</button>
</div>
</div>
);
}05 롱프레스 액션 메뉴 — 측정 누적면적 2.742% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 아코디언 리스트 UI 9가지 — 체크리스트까지
이 데모가 속한 글
아코디언 리스트 UI 9가지 — 체크리스트까지아코디언 리스트는 목록 행을 클릭했을 때 그 자리에서 높이가 부드럽게 늘어나며 상세 내용이 펼쳐지는 UI로, 별도 페이지 이동 없이 리스트 화면 안에서 정보를 더 보여주고 싶을 때 씁니다. 리스트 한 줄을 눌렀을 때 이 글의 데모 전부 보기


