05Long-press action menu
Holding a row for half a second pops up a star/archive/delete icon menu with a bouncy scale-in.
Best forLong-press a chat message for reactions, quick actions on a file list
pointerdown 타이머 500msscale bounce keyframes키보드용 더보기 버튼 대체

<main class="stage">
<div class="lp-row is-demo">
<span class="lp-row__title">Design meeting notes</span>
<button class="lp-row__more" type="button" aria-haspopup="true" aria-expanded="false" aria-label="Open item menu">
<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="Favorite">
<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="Archive">
<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="Delete">
<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 Long-press action menu — Measured area 2.742% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · 9 Accordion List UI Patterns (Copy-Paste)
The post this demo lives in
9 Accordion List UI Patterns (Copy-Paste)An accordion list is a UI where clicking a row grows its height in place to reveal detail content. Here are nine working See all demos in this post
If you like this effect - similar demos
Lists & tables05 Long-Press Ring to Schedule UndoTodo List UI: 9 Patterns From Ring Gauge to Rewind
Lists & tables02 Swipe multi-action stack9 Swipe to Delete List UI Patterns (Copy-Paste)
Lists & tables06 Basic Swipe Action That Reveals in One DirectionInfinite Scroll Implementation — 9 List UI Basics