UI Components Easy
Long-press Context Menu
Open an action menu after a hold, canceling cleanly when the pointer moves away.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
--bg: #0b1020;
--panel: #141c30;
--panel-2: #1b2540;
--line: #2b3d62;
--text: #eef2ff;
--muted: #a8b5d1;
--accent: #78a9ff;
--danger: #ff7b7b;
--hold: 500ms;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
padding: clamp(1rem, 4vw, 3rem);
background: radial-gradient(120% 80% at 50% 0%, #16203a 0%, var(--bg) 60%);
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-tap-highlight-color: transparent;
}
button { font: inherit; }
.demo {
width: min(560px, 100%);
margin: auto;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
padding: clamp(1.1rem, 3vw, 2rem);
box-shadow: 0 20px 70px #0006;
}
.demo__head h1 { margin: 0 0 .35rem; font-size: 1.3rem; letter-spacing: -.02em; }
.muted { margin: 0 0 1.5rem; color: var(--muted); font-size: .9rem; }
.cards { list-style: none; margin: 0; padding: 0; display: grid; gap: .625rem; }
.card {
position: relative;
width: 100%;
display: grid;
gap: .2rem;
padding: 1rem 1.1rem;
text-align: left;
color: inherit;
background: linear-gradient(180deg, var(--panel-2), #17203a);
border: 1px solid var(--line);
border-radius: 14px;
cursor: pointer;
overflow: hidden;
touch-action: none;
user-select: none;
transition: transform .18s ease, border-color .18s ease;
}
.card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.card[data-holding] { transform: scale(.985); border-color: #3d5c94; }
.card[aria-expanded="true"] { border-color: var(--accent); }
.card__title { font-weight: 600; }
.card__meta { font-size: .8rem; color: var(--muted); }
.card__fill {
position: absolute;
left: 0; bottom: 0;
height: 3px; width: 100%;
background: linear-gradient(90deg, var(--accent), #b78bfa);
transform: scaleX(0);
transform-origin: left;
opacity: 0;
}
.card[data-holding] .card__fill {
opacity: 1;
animation: fill var(--hold) linear forwards;
}
@keyframes fill { to { transform: scaleX(1); } }
.menu {
position: fixed;
z-index: 20;
min-width: 12rem;
padding: .35rem;
background: rgba(20, 28, 48, .97);
backdrop-filter: blur(12px);
border: 1px solid #49618e;
border-radius: 12px;
box-shadow: 0 18px 40px #000a;
transform-origin: top left;
animation: pop .14s ease-out;
}
.menu[hidden] { display: none; }
@keyframes pop {
from { opacity: 0; transform: scale(.94); }
to { opacity: 1; transform: scale(1); }
}
.menu__label {
margin: .25rem .55rem .4rem;
font-size: .72rem;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--muted);
}
.menu button {
display: block;
width: 100%;
padding: .55rem .6rem;
text-align: left;
color: var(--text);
background: none;
border: 0;
border-radius: 8px;
cursor: pointer;
}
.menu button:hover,
.menu button:focus-visible { background: #2a365a; outline: none; }
.menu button.is-danger { color: var(--danger); }
.menu button.is-danger:hover,
.menu button.is-danger:focus-visible { background: rgba(255, 123, 123, .14); }
.hint { margin: 1.25rem 0 0; font-size: .82rem; color: var(--muted); }
kbd {
padding: .05rem .35rem;
border: 1px solid var(--line);
border-bottom-width: 2px;
border-radius: 5px;
background: var(--panel-2);
font: inherit;
font-size: .78rem;
}
.status {
margin: .9rem 0 0;
font-size: .82rem;
color: var(--accent);
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .001ms !important;
transition-duration: .001ms !important;
}
.card[data-holding] .card__fill { transform: scaleX(1); opacity: 1; }
}// Long-press context menu — Pointer Events, pointer capture, move cancellation,
// keyboard fallback and full roving-focus menu semantics. Zero dependencies.
const HOLD_MS = 500;
const MOVE_TOLERANCE = 10; // px of slop before the hold is cancelled
const cards = document.getElementById("cards");
const menu = document.getElementById("menu");
const menuLabel = document.getElementById("menuLabel");
const status = document.getElementById("status");
const items = Array.from(menu.querySelectorAll('[role="menuitem"]'));
let timer = null;
let holdCard = null;
let origin = { x: 0, y: 0 };
let activePointer = null;
let opener = null;
let suppressClick = false;
const say = (msg) => { status.textContent = msg; };
/* ---------------------------------------------------------------- hold ---- */
function cancelHold(reason) {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
if (holdCard) {
if (activePointer !== null && holdCard.hasPointerCapture(activePointer)) {
holdCard.releasePointerCapture(activePointer);
}
holdCard.removeAttribute("data-holding");
holdCard = null;
}
activePointer = null;
if (reason) say(reason);
}
cards.addEventListener("pointerdown", (event) => {
const card = event.target.closest(".card");
if (!card || event.button !== 0) return;
closeMenu({ restoreFocus: false });
cancelHold();
holdCard = card;
activePointer = event.pointerId;
origin = { x: event.clientX, y: event.clientY };
card.setPointerCapture(event.pointerId);
card.setAttribute("data-holding", "");
say("Holding…");
timer = setTimeout(() => {
timer = null;
const target = holdCard;
// Release capture so the menu can receive its own pointer events.
if (target && activePointer !== null && target.hasPointerCapture(activePointer)) {
target.releasePointerCapture(activePointer);
}
if (target) target.removeAttribute("data-holding");
holdCard = null;
activePointer = null;
suppressClick = true; // the upcoming click is the tail of the hold
openMenu(target, origin.x, origin.y);
}, HOLD_MS);
});
cards.addEventListener("pointermove", (event) => {
if (!holdCard || event.pointerId !== activePointer) return;
const dx = event.clientX - origin.x;
const dy = event.clientY - origin.y;
if (Math.hypot(dx, dy) > MOVE_TOLERANCE) {
cancelHold("Cancelled — pointer moved past the 10px threshold.");
}
});
for (const type of ["pointerup", "pointercancel"]) {
cards.addEventListener(type, (event) => {
if (!holdCard || event.pointerId !== activePointer) return;
cancelHold("Cancelled — released before 500ms.");
});
}
// Swallow the click that ends a successful long press, and the native menu.
cards.addEventListener("click", (event) => {
if (suppressClick) {
suppressClick = false;
event.preventDefault();
event.stopPropagation();
}
});
cards.addEventListener("contextmenu", (event) => {
const card = event.target.closest(".card");
if (!card) return;
event.preventDefault();
openMenu(card, event.clientX, event.clientY);
});
/* ---------------------------------------------------------------- menu ---- */
function openMenu(card, x, y) {
if (!card) return;
opener = card;
menuLabel.textContent = card.dataset.name;
card.setAttribute("aria-expanded", "true");
menu.hidden = false;
menu.style.left = "0px";
menu.style.top = "0px";
// Flip/clamp inside the viewport.
const rect = menu.getBoundingClientRect();
const left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8));
const top = y + rect.height + 8 > window.innerHeight
? Math.max(8, y - rect.height)
: y;
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
items[0].focus();
say(`Menu open for ${card.dataset.name}.`);
}
function closeMenu({ restoreFocus = true } = {}) {
if (menu.hidden) return;
menu.hidden = true;
if (opener) {
opener.setAttribute("aria-expanded", "false");
if (restoreFocus) opener.focus();
}
opener = null;
}
menu.addEventListener("click", (event) => {
const item = event.target.closest("[role=menuitem]");
if (!item) return;
const name = menuLabel.textContent;
closeMenu();
say(`${item.dataset.action} → ${name}`);
});
menu.addEventListener("keydown", (event) => {
const index = items.indexOf(document.activeElement);
switch (event.key) {
case "ArrowDown":
event.preventDefault();
items[(index + 1) % items.length].focus();
break;
case "ArrowUp":
event.preventDefault();
items[(index - 1 + items.length) % items.length].focus();
break;
case "Home":
event.preventDefault();
items[0].focus();
break;
case "End":
event.preventDefault();
items[items.length - 1].focus();
break;
case "Escape":
event.preventDefault();
closeMenu();
say("Menu dismissed.");
break;
case "Tab":
closeMenu();
break;
}
});
/* --------------------------------------------------- keyboard fallback ---- */
cards.addEventListener("keydown", (event) => {
const card = event.target.closest(".card");
if (!card) return;
const isMenuKey =
event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey);
if (event.key === "Enter" || event.key === " " || isMenuKey) {
event.preventDefault();
const rect = card.getBoundingClientRect();
openMenu(card, rect.left + 16, rect.bottom - 8);
}
});
/* -------------------------------------------------------- dismissal ------- */
document.addEventListener("pointerdown", (event) => {
if (!menu.hidden && !menu.contains(event.target)) closeMenu({ restoreFocus: false });
}, true);
window.addEventListener("blur", () => closeMenu({ restoreFocus: false }));
window.addEventListener("resize", () => closeMenu({ restoreFocus: false }));
window.addEventListener("scroll", () => closeMenu({ restoreFocus: false }), true);<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Long-press Context Menu</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Long-press Context Menu">
<header class="demo__head">
<h1>Long-press context menu</h1>
<p class="muted">Press and hold a card for 500 ms. Moving more than 10 px, or releasing early, cancels the hold.</p>
</header>
<ul class="cards" id="cards">
<li>
<button class="card" type="button" aria-haspopup="menu" aria-expanded="false" data-name="Quarterly report.pdf">
<span class="card__fill" aria-hidden="true"></span>
<span class="card__title">Quarterly report.pdf</span>
<span class="card__meta">2.4 MB · PDF</span>
</button>
</li>
<li>
<button class="card" type="button" aria-haspopup="menu" aria-expanded="false" data-name="Launch deck.key">
<span class="card__fill" aria-hidden="true"></span>
<span class="card__title">Launch deck.key</span>
<span class="card__meta">18 MB · Keynote</span>
</button>
</li>
<li>
<button class="card" type="button" aria-haspopup="menu" aria-expanded="false" data-name="team-offsite.jpg">
<span class="card__fill" aria-hidden="true"></span>
<span class="card__title">team-offsite.jpg</span>
<span class="card__meta">6.1 MB · Image</span>
</button>
</li>
</ul>
<p class="hint">Keyboard: focus a card and press <kbd>Enter</kbd>, <kbd>Space</kbd> or <kbd>Shift</kbd>+<kbd>F10</kbd>. Arrows move through items, <kbd>Esc</kbd> closes.</p>
<div class="menu" id="menu" role="menu" aria-label="File actions" hidden>
<p class="menu__label" id="menuLabel"></p>
<button role="menuitem" type="button" tabindex="-1" data-action="Open">Open</button>
<button role="menuitem" type="button" tabindex="-1" data-action="Share">Share…</button>
<button role="menuitem" type="button" tabindex="-1" data-action="Rename">Rename</button>
<button role="menuitem" type="button" tabindex="-1" data-action="Delete" class="is-danger">Delete</button>
</div>
<p class="status" id="status" role="status" aria-live="polite">Idle.</p>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function GestureLongPressMenu() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>Long-press Context Menu</h2>
<p>Vanilla behavior maps cleanly to Pointer Events and DOM state.</p>
<div className="stack">
{items.map((item, index) => (
<button
className="item"
key={item}
onClick={() => setItems([...items.slice(0, index), ...items.slice(index + 1), item])}
>
{item}
</button>
))}
</div>
</section>
);
}Long-press Context Menu
Open an action menu after a hold, canceling cleanly when the pointer moves away.
Support notes
The examples use Pointer Events, capture, bounded transforms, and small snap thresholds. Production code should also wire keyboard alternatives and reduced-motion preferences.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.