UI Components Medium
Swipe-to-dismiss Card
Dismiss a card with a touch or pointer swipe, including a small velocity-aware threshold.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
--bg: #0a0c10;
--panel: #141821;
--line: rgba(255, 255, 255, 0.09);
--text: #e8ecf4;
--muted: #8b95a8;
--accent: #6ea8fe;
--danger: #f2555a;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: radial-gradient(120% 90% at 50% 0%, #151a24 0%, var(--bg) 60%);
color: var(--text);
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
.demo { width: min(440px, 92vw); padding: 32px 0 40px; }
.demo__head h1 { margin: 0 0 6px; font-size: 1.25rem; letter-spacing: -0.01em; }
.demo__head p { margin: 0 0 20px; color: var(--muted); font-size: 0.86rem; }
kbd {
font: inherit;
font-size: 0.78em;
padding: 1px 5px;
border: 1px solid var(--line);
border-radius: 5px;
background: #1b2130;
}
.stack { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
.card {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 14px 14px 16px;
border: 1px solid var(--line);
border-radius: 14px;
background: linear-gradient(180deg, #171c27, var(--panel));
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.35);
touch-action: pan-y;
cursor: grab;
will-change: transform, opacity;
outline: none;
}
.card:focus-visible { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(110, 168, 254, 0.25); }
.card[data-dragging="true"] { cursor: grabbing; box-shadow: 0 18px 40px rgba(0, 0, 0, 0.5); }
/* "will dismiss" wash, driven by --p (0..1) written from JS */
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: var(--danger);
opacity: calc(var(--p, 0) * 0.16);
}
.card__dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--accent); }
.card__dot[data-tone="b"] { background: #5ad1a4; }
.card__dot[data-tone="c"] { background: #f0b64b; }
.card__dot[data-tone="d"] { background: #b48bf5; }
.card__body { flex: 1; min-width: 0; }
.card__title { margin: 0; font-weight: 600; font-size: 0.92rem; }
.card__meta { margin: 2px 0 0; color: var(--muted); font-size: 0.76rem; }
.card__x {
flex: none;
width: 26px; height: 26px;
border: 1px solid var(--line);
border-radius: 8px;
background: transparent;
color: var(--muted);
font-size: 15px;
line-height: 1;
cursor: pointer;
}
.card__x:hover { color: var(--text); border-color: rgba(255, 255, 255, 0.2); }
.card.is-settling { transition: transform 220ms cubic-bezier(.22, 1, .36, 1); }
.card.is-out { transition: transform 260ms cubic-bezier(.4, 0, 1, 1), opacity 200ms ease; opacity: 0; }
.card.is-collapsing {
transition: height 200ms ease, margin 200ms ease, padding 200ms ease, opacity 200ms ease;
overflow: hidden;
}
.empty { margin: 18px 0 0; text-align: center; color: var(--muted); font-size: 0.86rem; }
.reset {
margin: 22px auto 0;
display: block;
padding: 8px 16px;
border: 1px solid var(--line);
border-radius: 10px;
background: #1b2130;
color: var(--text);
font: inherit;
font-size: 0.83rem;
cursor: pointer;
}
.reset:hover { border-color: var(--accent); }
.live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.card, .card.is-settling, .card.is-out, .card.is-collapsing { transition-duration: 1ms !important; }
}/* Swipe-to-dismiss: Pointer Events + pointer capture, axis lock,
rubber-band resistance, velocity-aware threshold, height collapse,
keyboard fallback and reduced-motion support. */
(() => {
const stack = document.getElementById("stack");
const empty = document.getElementById("empty");
const live = document.getElementById("live");
const resetBtn = document.getElementById("reset");
if (!stack) return;
const DIST_RATIO = 0.35; // fraction of card width that commits a dismiss
const VELOCITY = 0.5; // px/ms that commits regardless of distance
const SLOP = 8; // px before we decide the gesture is horizontal
const template = stack.innerHTML;
const reduced = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
const state = new WeakMap(); // card -> gesture record
function setX(card, dx) {
const w = card.offsetWidth || 1;
const p = Math.min(1, Math.abs(dx) / (w * DIST_RATIO));
card.style.transform = `translateX(${dx}px) rotate(${(dx / w) * 6}deg)`;
card.style.opacity = String(1 - p * 0.35);
card.style.setProperty("--p", p.toFixed(3));
}
function clearX(card) {
card.style.transform = "";
card.style.opacity = "";
card.style.setProperty("--p", "0");
}
function rubber(dx, limit) {
// linear until `limit`, then logarithmic resistance
const a = Math.abs(dx);
if (a <= limit) return dx;
return Math.sign(dx) * (limit + Math.log10(1 + (a - limit) / limit) * limit * 0.6);
}
function announce(msg) { if (live) live.textContent = msg; }
function updateEmpty() {
const left = stack.querySelectorAll(".card").length;
if (empty) empty.hidden = left > 0;
}
/* ---- commit / cancel ---------------------------------------------- */
function dismiss(card, dir) {
if (card.dataset.gone === "1") return;
card.dataset.gone = "1";
const title = card.querySelector(".card__title");
const label = title ? title.textContent : "Card";
// move focus somewhere sensible before the node leaves the tree
if (card.contains(document.activeElement)) {
const next = card.nextElementSibling || card.previousElementSibling;
(next || resetBtn || document.body).focus?.();
}
card.classList.remove("is-settling");
card.classList.add("is-out");
card.style.transform = `translateX(${dir * (card.offsetWidth + 80)}px) rotate(${dir * 8}deg)`;
const collapse = () => {
const h = card.offsetHeight;
card.style.height = h + "px";
card.classList.add("is-collapsing");
// force layout so the height transition has a start value
void card.offsetHeight;
card.style.height = "0px";
card.style.marginTop = "-10px";
card.style.paddingTop = "0";
card.style.paddingBottom = "0";
const done = () => { card.remove(); updateEmpty(); };
if (reduced()) done();
else setTimeout(done, 220);
};
if (reduced()) collapse();
else setTimeout(collapse, 200);
announce(label + " dismissed");
}
function settle(card) {
card.classList.add("is-settling");
clearX(card);
setTimeout(() => card.classList.remove("is-settling"), 240);
}
/* ---- pointer gesture ---------------------------------------------- */
stack.addEventListener("pointerdown", (e) => {
const card = e.target.closest(".card");
if (!card || card.dataset.gone === "1") return;
if (e.target.closest(".card__x")) return; // let the button handle it
if (e.button !== 0 && e.pointerType === "mouse") return;
card.classList.remove("is-settling");
state.set(card, {
id: e.pointerId,
x0: e.clientX,
y0: e.clientY,
lastX: e.clientX,
lastT: e.timeStamp,
v: 0,
axis: null,
});
});
stack.addEventListener("pointermove", (e) => {
const card = e.target.closest(".card");
const s = card && state.get(card);
if (!s || s.id !== e.pointerId) return;
const dx = e.clientX - s.x0;
const dy = e.clientY - s.y0;
if (s.axis === null) {
if (Math.hypot(dx, dy) < SLOP) return;
s.axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
if (s.axis === "x") {
card.setPointerCapture(e.pointerId);
card.dataset.dragging = "true";
} else {
state.delete(card); // vertical: hand back to the scroller
return;
}
}
const dt = e.timeStamp - s.lastT;
if (dt > 0) s.v = (e.clientX - s.lastX) / dt;
s.lastX = e.clientX;
s.lastT = e.timeStamp;
e.preventDefault();
setX(card, rubber(dx, card.offsetWidth * 0.5));
});
function endGesture(e) {
const card = e.target.closest(".card");
const s = card && state.get(card);
if (!s || s.id !== e.pointerId) return;
state.delete(card);
delete card.dataset.dragging;
if (card.hasPointerCapture?.(e.pointerId)) card.releasePointerCapture(e.pointerId);
if (s.axis !== "x") return;
const dx = e.clientX - s.x0;
const farEnough = Math.abs(dx) > card.offsetWidth * DIST_RATIO;
const fastEnough = Math.abs(s.v) > VELOCITY && Math.sign(s.v) === Math.sign(dx) && Math.abs(dx) > 20;
if (farEnough || fastEnough) dismiss(card, Math.sign(dx) || 1);
else settle(card);
}
stack.addEventListener("pointerup", endGesture);
stack.addEventListener("pointercancel", endGesture);
/* ---- keyboard + button fallbacks ---------------------------------- */
stack.addEventListener("keydown", (e) => {
const card = e.target.closest(".card");
if (!card) return;
if (e.key === "ArrowRight") { e.preventDefault(); dismiss(card, 1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); dismiss(card, -1); }
else if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); dismiss(card, 1); }
else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
const cards = [...stack.querySelectorAll(".card")];
const i = cards.indexOf(card);
const next = cards[i + (e.key === "ArrowDown" ? 1 : -1)];
if (next) { e.preventDefault(); next.focus(); }
}
});
stack.addEventListener("click", (e) => {
const btn = e.target.closest(".card__x");
if (btn) dismiss(btn.closest(".card"), 1);
});
resetBtn?.addEventListener("click", () => {
stack.innerHTML = template;
stack.querySelectorAll(".card").forEach(clearX);
updateEmpty();
announce("All cards restored");
stack.querySelector(".card")?.focus();
});
updateEmpty();
})();<link rel="stylesheet" href="style.css">
<main class="demo" data-demo="Swipe-to-dismiss Card">
<header class="demo__head">
<h1>Swipe to dismiss</h1>
<p>Drag a card sideways, or focus it and press <kbd>←</kbd> / <kbd>→</kbd> / <kbd>Delete</kbd>.</p>
</header>
<ul class="stack" id="stack" aria-label="Notifications">
<li class="card" tabindex="0" aria-roledescription="dismissible card">
<span class="card__dot" data-tone="a"></span>
<div class="card__body">
<p class="card__title">Deploy finished</p>
<p class="card__meta">web · 2 min ago</p>
</div>
<button class="card__x" type="button" aria-label="Dismiss Deploy finished">×</button>
</li>
<li class="card" tabindex="0" aria-roledescription="dismissible card">
<span class="card__dot" data-tone="b"></span>
<div class="card__body">
<p class="card__title">3 new reviews</p>
<p class="card__meta">pull requests · 11 min ago</p>
</div>
<button class="card__x" type="button" aria-label="Dismiss 3 new reviews">×</button>
</li>
<li class="card" tabindex="0" aria-roledescription="dismissible card">
<span class="card__dot" data-tone="c"></span>
<div class="card__body">
<p class="card__title">Storage at 82%</p>
<p class="card__meta">infra · 1 h ago</p>
</div>
<button class="card__x" type="button" aria-label="Dismiss Storage at 82%">×</button>
</li>
<li class="card" tabindex="0" aria-roledescription="dismissible card">
<span class="card__dot" data-tone="d"></span>
<div class="card__body">
<p class="card__title">Invoice paid</p>
<p class="card__meta">billing · yesterday</p>
</div>
<button class="card__x" type="button" aria-label="Dismiss Invoice paid">×</button>
</li>
</ul>
<p class="empty" id="empty" hidden>Inbox zero.</p>
<button class="reset" id="reset" type="button">Restore all</button>
<p class="live" id="live" role="status" aria-live="polite"></p>
</main>
<script src="script.js"></script>import { useState } from "react";
export function GestureSwipeDismiss() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>Swipe-to-dismiss Card</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>
);
}Swipe-to-dismiss Card
Dismiss a card with a touch or pointer swipe, including a small velocity-aware threshold.
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.