GODRICH

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

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.

pointerdown/move/uptransform:translateX임계값 스냅
// 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.

stagger revealtransform 순차 지연touch-action: pan-y
.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.

touch 이동 거리elastic ease-outspinner rotate keyframes
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.

pointerdown 드래그형제 요소 reflowdrop index 계산
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.

aria-sort 속성click 토글화살표 rotate transform
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.

position:sticky top+leftz-index 겹침 순서border 겹선 보정
// 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.

shimmer gradient sweeparia-busybackground-position keyframes
.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."

indeterminate checkboxtranslateY(120%)selectall 동기화
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.

current page stateellipsis collapse 계산prev/next disabled
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.

Enter the archive password

The password is inside this article. You will find it as you read.