9 Swipe to Delete List UI Patterns (Copy-Paste)
Swipe to delete is dragging a list row sideways so a delete button slides out from behind it — the app gesture you keep wanting on the web.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Swipe-to-delete reveal
- 02 Swipe multi-action stack
- 03 Pull-to-refresh spinner
- 04 Drag-handle reorder
- 05 Click-to-sort header
- 06 Frozen header + first column
- 07 Skeleton shimmer rows
- 08 Bulk-select toolbar
- 09 Numbered pagination
The nine aren't ranked by popularity — they follow the order a list actually goes through. First come one-row gestures (01-02 swipe, 03 pull to refresh), then whole-list operations (04-05 reordering and sorting, 06 pinned viewing), and finally what a list needs once it gets too long to show at once (07 loading, 08 bulk selection, 09 paging).
01Swipe-to-delete reveal
Dragging a row left reveals a red delete button behind it, and dragging past halfway auto-dismisses the whole row. The button is a real, always-present <button>, so tabbing to the row and pressing Delete works just as well as a finger.
// pointermove sets --dx directly, then two thresholds decide what happens on release
content.addEventListener('pointermove', function (e) {
dx = Math.min(0, Math.max(-170, e.clientX - startX));
content.style.transform = 'translateX(' + dx + 'px)';
});
function end() {
if (dx < -140) { removeRow(); } // past the delete threshold
else content.style.transform = dx < -44 ? 'translateX(-88px)' : '';
}
02Swipe multi-action stack
The further you drag, the more buttons — star, archive, and delete — pop in from the side and stack up in order. All three are real buttons, so tabbing to the row reveals them for keyboard users too.
.action {
opacity: 0;
transform: translateY(8px);
transition: opacity .22s, transform .22s;
}
// keyboard focus landing on the row (not just a hover) reveals the same three buttons
.row:focus-within .action { opacity: 1; transform: translateY(0); }
.action--fav { animation-delay: 0ms; }
.action--archive { animation-delay: 80ms; }
.action--del { animation-delay: 160ms; }
03Pull-to-refresh spinner
Dragging down from the top of the list stretches with rubber-band resistance, then releases into a spinning refresh. A visible refresh button triggers the exact same animation for anyone who can't perform the drag.
function end() {
if (dy > 44) refresh(); // pulled past the halfway mark
else body.style.transform = ''; // otherwise it springs back
}
function refresh() {
body.style.transform = 'translateY(56px)';
setTimeout(function () { body.style.transform = ''; }, 900);
}
04Drag-handle reorder
Grabbing the six-dot handle on the left and dragging up or down nudges the rows it passes aside and reorders the list. Without a pointer at all, focusing the handle and pressing the arrow keys reorders it the same way.
handle.addEventListener('keydown', function (e) {
var sib = e.key === 'ArrowDown' ? row.nextElementSibling
: e.key === 'ArrowUp' ? row.previousElementSibling : null;
if (!sib) return;
e.preventDefault();
e.key === 'ArrowDown' ? list.insertBefore(sib, row) : list.insertBefore(row, sib);
handle.focus({ preventScroll: true }); // keeps the parent page from jumping under an iframe
});
05Click-to-sort header
Clicking a column header flips its arrow and re-sorts the rows by that column, toggling ascending and descending. The current state is exposed through aria-sort, so a screen reader announces it the same way a sighted user sees the arrow flip.
btn.addEventListener('click', function () {
var dir = btn.getAttribute('aria-sort') === 'ascending' ? 'descending' : 'ascending';
ths.forEach(function (b) { b.setAttribute('aria-sort', 'none'); });
btn.setAttribute('aria-sort', dir);
rows.sort(function (a, b) {
var cmp = a[key] > b[key] ? 1 : a[key] < b[key] ? -1 : 0;
return dir === 'ascending' ? cmp : -cmp;
}).forEach(function (r) { tbody.appendChild(r); });
});
06Frozen header + first column
Scroll the table in any direction and the top header row plus the leftmost column stay pinned in place. position: sticky gets both a top and a left, and only the one cell where both apply needs a higher z-index.
// header row — stays put while the table scrolls vertically
thead th {
position: sticky;
top: 0;
z-index: 2;
}
// first column — stays put while the table scrolls horizontally
tbody th[scope="row"] {
position: sticky;
left: 0;
z-index: 1;
}
// the one cell where both apply needs to sit above both layers
.corner { position: sticky; top: 0; left: 0; z-index: 3; }
07Skeleton shimmer rows
Before data arrives, gray bar rows sweep a light shimmer left to right, previewing where content will land. Keeping the sweep's highlight far lighter than its base color is what makes the motion actually read in a screenshot or a gif. A toggle button swaps the skeleton for loaded content on demand, so you can compare both states side by side.
.bar {
background: linear-gradient(90deg, #d8d8de 25%, rgba(255, 255, 255, .95) 50%, #d8d8de 75%);
background-size: 200% 100%;
animation: shimmer 1.4s linear infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
08Bulk-select toolbar
Checking even one row checkbox slides up a bottom toolbar with bulk actions like delete and move. When only some rows are checked, the select-all box switches to an indeterminate state to show it isn't "all or nothing."
function sync() {
var n = items.filter(function (c) { return c.checked; }).length;
all.checked = n === items.length;
all.indeterminate = n > 0 && n < items.length; // neither none nor all are checked
list.classList.toggle('has-selection', n > 0); // this is what slides the toolbar up
}
09Numbered pagination
Page-number buttons sit in a row, and once there are too many, the middle collapses into an ellipsis with only the current page highlighted. This demo runs on eight pages, so only the first, the last, and whatever sits next to the current one keeps its own number.
function pages(total, current) {
var out = [];
for (var p = 1; p <= total; p++) {
// keep a number for the first page, the last page, or a neighbor of the current one
if (p === 1 || p === total || Math.abs(p - current) <= 1) {
out.push(p);
} else if (out[out.length - 1] !== '...') {
out.push('...'); // a run of skipped numbers collapses into one dot
}
}
return out;
}
Where list swipe UI breaks
Items 01 and 02 both call setPointerCapture the instant a drag starts. Skip that one line (an easy thing to forget) and a finger sliding just outside the button's edge drops the pointermove stream, so the swipe stalls halfway with no obvious reason why. The second trap turned up while building item 08's toolbar, which is supposed to sit off-screen until a row gets checked: re-rendering this same 480x300 demo at a 320px width caught it pushing document.documentElement.scrollHeight past the viewport before the list around it was wrapped in overflow: hidden — that wrapper is what ships in this zip now. The zip holding all nine files opens with kjj4ftra, and once it's unzipped, the vanilla and React folders sit side by side for comparison.
Accessibility — what reduced-motion does to each of the nine
Under prefers-reduced-motion: reduce, items 01 through 05 and 08 keep transition: none and drop only the timing, while item 06 swaps its smooth scroll for scroll-behavior: auto — either way, a swipe, a sort, or a pin still happens exactly as before, just instantly instead of eased. Item 07 (skeleton) is handled differently: instead of killing the shimmer outright, its duration stretches to 3.2 seconds, so the loading signal stays but the flicker that can bother some viewers is cut way down. Item 09 (pagination) only ever had a color transition to begin with, so there's nothing further to strip out. Every item keeps its focus outline, so Tab and the arrow keys alone can reach and operate all nine without a mouse.
More list and table patterns live in the Real-world UI category, and what this site actually is gets explained on the about page. The scroll-container rules behind position: sticky are documented on MDN, and the pointer-capture API behind items 01 and 02 is covered in the MDN Pointer events docs.
FAQ — Frequently Asked Questions
Does swipe to delete really work without a gesture library?
Yes. Items 01 and 02 here are built from three events — pointerdown, pointermove, and pointerup — plus transform: translateX(), with no separate gesture library involved.
Why transform instead of top/left for dragging?
Changing top, left, or height forces the browser to recompute layout on every single frame, which is where dragging stutters. Moving only transform and opacity updates a composited layer instead, with no layout recalculation at all.
Does the vanilla code differ from the React version?
No — every value driving the movement matches. The vanilla demo loops nonstop under an is-demo class so the gallery has something to show, while the React components only respond to real pointer and click events through useState and useRef.