UI Components Medium
Long Press Menu
A context menu triggered by a long press (500ms hold) on mobile, and right-click on desktop. Dismisses on outside tap or Escape. No libraries.
Open in Lab
MCP
vanilla-js css
Targets: JS HTML
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f0f2f5;
min-height: 100vh;
}
.page {
max-width: 480px;
margin: 0 auto;
min-height: 100vh;
background: #fff;
}
header {
padding: 24px 20px 16px;
border-bottom: 1px solid #f0f0f0;
}
header h1 {
font-size: 22px;
font-weight: 700;
color: #111;
}
header p {
font-size: 13px;
color: #888;
margin-top: 4px;
}
/* Grid */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 16px;
}
.card {
border-radius: 14px;
overflow: hidden;
cursor: pointer;
background: #f3f4f6;
transition: transform 0.15s ease, box-shadow 0.15s ease;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.card:active {
transform: scale(0.97);
}
.card.pressing {
animation: press-pulse 0.5s ease forwards;
}
@keyframes press-pulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.4);
}
50% {
transform: scale(0.97);
box-shadow: 0 0 0 8px rgba(99, 102, 241, 0.15);
}
100% {
transform: scale(0.97);
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0);
}
}
.card-img {
height: 110px;
}
.img1 {
background: linear-gradient(135deg, #f59e0b, #ef4444);
}
.img2 {
background: linear-gradient(135deg, #0ea5e9, #6366f1);
}
.img3 {
background: linear-gradient(135deg, #10b981, #0ea5e9);
}
.img4 {
background: linear-gradient(135deg, #8b5cf6, #ec4899);
}
.img5 {
background: linear-gradient(135deg, #f97316, #fbbf24);
}
.img6 {
background: linear-gradient(135deg, #6366f1, #06b6d4);
}
.card-label {
padding: 10px 12px;
font-size: 13px;
font-weight: 600;
color: #111;
}
/* Context menu */
.context-menu {
position: fixed;
background: rgba(245, 245, 247, 0.92);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 14px;
min-width: 200px;
z-index: 1000;
overflow: hidden;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.18);
opacity: 0;
transform: scale(0.9);
transform-origin: top left;
transition: opacity 0.15s ease, transform 0.15s ease;
pointer-events: none;
}
.context-menu.visible {
opacity: 1;
transform: scale(1);
pointer-events: all;
}
.menu-title {
padding: 12px 16px 8px;
font-size: 12px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.menu-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 16px;
border: none;
background: none;
font-size: 15px;
color: #111;
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.menu-item:active {
background: rgba(0, 0, 0, 0.05);
}
.menu-item svg {
width: 18px;
height: 18px;
flex-shrink: 0;
color: #555;
}
.menu-item.danger {
color: #ef4444;
}
.menu-item.danger svg {
color: #ef4444;
}
.menu-divider {
height: 1px;
background: rgba(0, 0, 0, 0.08);
margin: 4px 0;
}
/* Toast */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(80px);
background: #111;
color: #fff;
padding: 10px 20px;
border-radius: 24px;
font-size: 14px;
font-weight: 500;
transition: transform 0.3s ease, opacity 0.3s ease;
opacity: 0;
pointer-events: none;
white-space: nowrap;
}
.toast.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}const menu = document.getElementById("contextMenu");
const menuTitle = document.getElementById("menuTitle");
const toast = document.getElementById("toast");
const LONG_PRESS_DURATION = 500;
let pressTimer = null;
let activeTarget = null;
function showMenu(x, y, title) {
menuTitle.textContent = title || "Options";
// Position near the press point
menu.style.left = "0px";
menu.style.top = "0px";
menu.classList.add("visible");
const menuW = menu.offsetWidth;
const menuH = menu.offsetHeight;
const vw = window.innerWidth;
const vh = window.innerHeight;
const left = Math.min(Math.max(x, 8), vw - menuW - 8);
const top = Math.min(Math.max(y, 8), vh - menuH - 8);
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
}
function hideMenu() {
menu.classList.remove("visible");
if (activeTarget) {
activeTarget.classList.remove("pressing");
activeTarget = null;
}
}
function showToast(msg) {
toast.textContent = msg;
toast.classList.add("show");
setTimeout(() => toast.classList.remove("show"), 2000);
}
// Attach long-press to all cards
document.querySelectorAll("[data-long-press]").forEach((el) => {
const title = el.dataset.title || "";
// Touch events
el.addEventListener(
"touchstart",
(e) => {
const touch = e.touches[0];
activeTarget = el;
el.classList.add("pressing");
pressTimer = setTimeout(() => {
showMenu(touch.clientX, touch.clientY, title);
}, LONG_PRESS_DURATION);
},
{ passive: true }
);
el.addEventListener(
"touchmove",
() => {
clearTimeout(pressTimer);
el.classList.remove("pressing");
},
{ passive: true }
);
el.addEventListener(
"touchend",
() => {
clearTimeout(pressTimer);
el.classList.remove("pressing");
},
{ passive: true }
);
// Desktop right-click
el.addEventListener("contextmenu", (e) => {
e.preventDefault();
showMenu(e.clientX, e.clientY, title);
});
});
// Menu item actions
menu.querySelectorAll(".menu-item").forEach((btn) => {
btn.addEventListener("click", () => {
const action = btn.dataset.action;
const title = menuTitle.textContent;
const messages = {
share: `Shared "${title}"`,
download: `Saved "${title}"`,
favorite: `Added "${title}" to favorites`,
delete: `Deleted "${title}"`,
};
showToast(messages[action] || "Done");
hideMenu();
});
});
// Close on outside click
document.addEventListener("click", (e) => {
if (!menu.contains(e.target)) hideMenu();
});
document.addEventListener(
"touchstart",
(e) => {
if (!menu.contains(e.target)) hideMenu();
},
{ passive: true }
);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") hideMenu();
});<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="style.css" />
<title>Long Press Menu</title>
</head>
<body>
<div class="page">
<header>
<h1>Long Press Menu</h1>
<p>Hold any card for 500ms (or right-click on desktop)</p>
</header>
<div class="grid">
<div class="card" data-long-press data-title="Mountain Sunrise">
<div class="card-img img1"></div>
<div class="card-label">Mountain Sunrise</div>
</div>
<div class="card" data-long-press data-title="Ocean Waves">
<div class="card-img img2"></div>
<div class="card-label">Ocean Waves</div>
</div>
<div class="card" data-long-press data-title="Forest Path">
<div class="card-img img3"></div>
<div class="card-label">Forest Path</div>
</div>
<div class="card" data-long-press data-title="City Lights">
<div class="card-img img4"></div>
<div class="card-label">City Lights</div>
</div>
<div class="card" data-long-press data-title="Desert Dunes">
<div class="card-img img5"></div>
<div class="card-label">Desert Dunes</div>
</div>
<div class="card" data-long-press data-title="Aurora Sky">
<div class="card-img img6"></div>
<div class="card-label">Aurora Sky</div>
</div>
</div>
</div>
<!-- Context menu -->
<div class="context-menu" id="contextMenu" role="menu" aria-label="Item actions">
<div class="menu-title" id="menuTitle"></div>
<button class="menu-item" data-action="share" role="menuitem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
Share
</button>
<button class="menu-item" data-action="download" role="menuitem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Save to Camera Roll
</button>
<button class="menu-item" data-action="favorite" role="menuitem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
Add to Favorites
</button>
<div class="menu-divider"></div>
<button class="menu-item danger" data-action="delete" role="menuitem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Delete
</button>
</div>
<!-- Toast feedback -->
<div class="toast" id="toast"></div>
<script src="script.js"></script>
</body>
</html>Long Press Menu
A context menu that appears after a 500ms press-and-hold on mobile, and on right-click for desktop users. The menu positions itself near the press point while staying within the viewport.
How it works
touchstartstarts a 500mssetTimeout;touchmoveortouchendcancels it- When the timer fires, the press coordinates are recorded and the menu is shown
- Desktop users can also trigger it via
contextmenu(right-click) - The menu position is clamped so it never overflows the viewport edges
- Tapping outside or pressing Escape dismisses the menu
Features
- Works on grid items, list items, images, or any element with
data-long-press - A haptic-style visual pulse animation plays on the pressed element
- Menu items can carry
data-actionattributes for event handling
When to use it
- Photo galleries (select, share, delete)
- File browsers
- Social feed cards (react, share, save, report)