GODRICH

9 mẫu List UI: swipe to delete css và phân trang

Swipe to delete css là vuốt một dòng để lộ nút xóa phía sau — thao tác bạn hay thấy trong app, giờ có luôn trên web.

Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip

Chín mục dưới đây không xếp theo độ phổ biến mà theo đúng trình tự mà một danh sách thực sự trải qua. Trước hết là thao tác trên một dòng (01, 02 vuốt, 03 kéo để làm mới), sau đó là thao tác trên cả danh sách (04, 05 sắp xếp lại, 06 cố định khi cuộn), và cuối cùng là những gì một danh sách cần khi có quá nhiều dòng (07 trạng thái đang tải, 08 chọn hàng loạt, 09 phân trang).

01Vuốt để xóa (reveal)

Vuốt một dòng sang trái sẽ hiện nút xóa màu đỏ phía sau, vuốt quá nửa thì cả dòng tự biến mất. Nút vẫn luôn là một <button> thật nằm trong DOM, nên bấm Tab rồi bấm phím Delete cũng xóa được y hệt.

pointerdown/move/uptransform:translateX임계값 스냅
// pointermove ghi thẳng giá trị --dx, rồi hai ngưỡng quyết định việc xảy ra khi thả tay
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(); }                  // vượt ngưỡng xóa
  else content.style.transform = dx < -44 ? 'translateX(-88px)' : '';
}

02Vuốt hiện nhiều hành động

Vuốt càng xa, các nút yêu thích, lưu trữ, xóa càng lần lượt xuất hiện và xếp cạnh nhau. Cả ba đều là button thật, nên chỉ cần Tab đến dòng đó, người dùng bàn phím cũng thấy đủ ba nút này.

stagger revealtransform 순차 지연touch-action: pan-y
.action {
  opacity: 0;
  transform: translateY(8px);
  transition: opacity .22s, transform .22s;
}
// không chỉ hover — focus bằng bàn phím rơi vào dòng cũng hiện đủ ba nút này
.row:focus-within .action { opacity: 1; transform: translateY(0); }
.action--fav     { animation-delay: 0ms; }
.action--archive { animation-delay: 80ms; }
.action--del     { animation-delay: 160ms; }

03Kéo để làm mới

Kéo xuống từ đầu danh sách sẽ giãn ra như dây thun, thả tay thì spinner quay và làm mới dữ liệu. Nút làm mới hiển thị sẵn cũng chạy đúng animation này, nên ai không kéo được bằng tay vẫn dùng được.

touch 이동 거리elastic ease-outspinner rotate keyframes
function end() {
  if (dy > 44) refresh();            // kéo quá nửa ngưỡng
  else body.style.transform = '';    // không thì bật lại vị trí cũ
}
function refresh() {
  body.style.transform = 'translateY(56px)';
  setTimeout(function () { body.style.transform = ''; }, 900);
}

04Kéo để sắp xếp lại

Giữ tay cầm sáu chấm bên trái và kéo lên xuống, các dòng nằm trên đường kéo sẽ nhường chỗ và thứ tự thay đổi. Không dùng chuột hay ngón tay cũng không sao — focus vào tay cầm rồi bấm phím mũi tên cũng sắp xếp lại y hệt.

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 });   // tránh trang cha bị cuộn cưỡng bức khi nằm trong iframe
});

05Header bấm để sắp xếp

Bấm vào tiêu đề cột sẽ lật mũi tên và sắp xếp lại các dòng theo cột đó, xen kẽ tăng dần và giảm dần. Trạng thái sắp xếp cũng được đưa vào aria-sort, nên trình đọc màn hình báo đúng như người nhìn thấy mũi tên lật.

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); });
});

06Cố định header + cột đầu

Cuộn bảng theo cả hai chiều thì hàng header trên cùng và cột ngoài cùng bên trái vẫn dính nguyên tại chỗ. Chỉ cần cho position: sticky cả top lẫn left, riêng ô duy nhất nơi hai điều kiện đó chồng lên nhau thì nâng z-index cao hơn một bậc.

position:sticky top+leftz-index 겹침 순서border 겹선 보정
// hàng header — dính lại khi cuộn theo chiều dọc
thead th {
  position: sticky;
  top: 0;
  z-index: 2;
}
// cột đầu tiên — dính lại khi cuộn theo chiều ngang
tbody th[scope="row"] {
  position: sticky;
  left: 0;
  z-index: 1;
}
// ô góc, nơi cả hai điều kiện chồng nhau, cần nằm trên cùng
.corner { position: sticky; top: 0; left: 0; z-index: 3; }

07Danh sách skeleton shimmer

Trước khi có dữ liệu, các hàng thanh xám lướt ánh sáng qua lại, báo trước vị trí nội dung sắp hiện ra. Độ tương phản giữa màu nền và dải sáng càng lớn thì trong ảnh chụp màn hình hay gif càng thấy rõ ánh sáng đang lướt.

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; }
}

08Thanh chọn nhiều mục

Chỉ cần chọn một checkbox dòng, thanh công cụ hành động hàng loạt (xóa, di chuyển) sẽ trượt lên từ dưới. Khi chỉ một phần dòng được chọn, checkbox chọn tất cả chuyển sang trạng thái indeterminate để báo rằng đây chưa phải "tất cả".

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;    // chỉ một phần được chọn, không phải tất cả
  list.classList.toggle('has-selection', n > 0);    // đây là điều kiện kéo thanh công cụ lên
}

09Phân trang dạng số

Các nút số trang xếp thành hàng, khi quá nhiều trang thì phần giữa gộp lại thành dấu ba chấm, chỉ trang hiện tại được làm nổi bật. Bản demo này có tổng cộng 8 trang, nên chỉ trang đầu, trang cuối và các trang sát bên trang hiện tại còn giữ số riêng.

current page stateellipsis collapse 계산prev/next disabled
function pages(total, current) {
  var out = [];
  for (var p = 1; p <= total; p++) {
    // giữ số cho trang đầu, trang cuối, hoặc trang sát bên trang hiện tại
    if (p === 1 || p === total || Math.abs(p - current) <= 1) {
      out.push(p);
    } else if (out[out.length - 1] !== '...') {
      out.push('...');           // một dải số liên tiếp bị bỏ qua gộp thành một dấu chấm
    }
  }
  return out;
}

Bẫy nằm ở đâu khi làm list swipe css

Cả 01 và 02 đều gọi setPointerCapture ngay khi bắt đầu kéo. Bỏ sót đúng một dòng này (lỗi rất dễ mắc) thì chỉ cần ngón tay lệch ra khỏi mép nút một chút, chuỗi pointermove bị đứt và thao tác vuốt dừng giữa chừng mà không rõ lý do. Bẫy thứ hai thật sự xuất hiện khi làm thanh công cụ ở mục 08 — vốn phải nằm ngoài màn hình cho đến khi có mục được chọn: dựng lại đúng bản demo 480×300 này ở chiều rộng 320px sẽ bắt được nó đẩy document.documentElement.scrollHeight vượt quá viewport trước khi cả danh sách được bọc trong overflow: hidden — bản đã bọc đó mới là bản nằm trong zip này. Mật khẩu giải nén chín tệp này là kjj4ftra, giải nén ra sẽ thấy hai thư mục vanilla và react cùng cấu trúc để so sánh trực tiếp.

Khả năng tiếp cận

Khi prefers-reduced-motion: reduce được bật, các mục 01 đến 06 và 08 chỉ bỏ thời gian chuyển tiếp bằng transition: none — kết quả của vuốt, sắp xếp hay cố định vẫn xảy ra như cũ, chỉ là tức thì thay vì mượt dần. Mục 07 (skeleton) được xử lý khác: thay vì tắt hẳn hiệu ứng shimmer, chu kỳ được kéo dài đến 3,2 giây, giữ lại tín hiệu "đang tải" nhưng giảm mạnh độ nhấp nháy. Mục 09 (phân trang) vốn chỉ có chuyển màu nên không còn gì để bớt thêm. Cả chín mục đều giữ nguyên viền focus, nên chỉ cần Tab và phím mũi tên là thao tác được hết mà không cần chuột.

Các chủ đề list, bảng khác nằm trong danh mục UI thực chiến, còn muốn biết về website này thì xem ở giới thiệu. Quy tắc container cuộn của position: sticky được ghi trong tài liệu MDN, còn setPointerCapture mà mục 01, 02 dùng nằm trong tài liệu MDN Pointer events.

FAQ

Swipe to delete css có thật sự không cần thư viện nào không?

Có. Mục 01 và 02 ở đây chỉ dùng ba sự kiện pointerdown, pointermove, pointerup cùng transform: translateX(), không có thư viện gesture nào khác đi kèm.

Vì sao dùng transform thay vì top/left khi kéo?

Đổi giá trị top, left hay height buộc trình duyệt phải tính lại layout ở mỗi khung hình, đó là lý do thao tác kéo dễ bị giật. Chỉ đổi transform và opacity thì chỉ có composited layer được cập nhật, không cần tính lại layout, nên chuyển động bám theo ngón tay mượt hơn hẳn.

Code vanilla và react có khác nhau không?

Chuyển động thì giống hệt nhau. Bản demo vanilla chạy lặp liên tục bằng class is-demo để có nội dung hiển thị sẵn trong gallery, còn các component React chỉ phản ứng với sự kiện pointer và click thật thông qua useStateuseRef.

Nhập mật khẩu file nén

Mật khẩu nằm ngay trong bài viết này. Đọc tới đó bạn sẽ thấy.