스와이프 삭제 리스트 UI 9가지 — 페이지네이션까지
스와이프 삭제는 리스트 항목을 옆으로 밀면 뒤에 숨어 있던 삭제 버튼이 드러나는 동작으로, 인스타·배민 앱에서 본 그 동작입니다. 이 글은 스와이프 삭제를 포함해 당겨서 새로고침·드래그 정렬·고정 헤더·페이지네이션까지 리스트 인터랙션 아홉 가지를 실제 동작하는 코드로 정리합니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 스와이프 삭제 리빌
- 02 스와이프 다중 액션
- 03 당겨서 새로고침
- 04 드래그 핸들 순서 변경
- 05 클릭 정렬 테이블 헤더
- 06 고정 헤더 + 첫 열
- 07 스켈레톤 로딩 리스트
- 08 체크박스 다중 선택 툴바
- 09 숫자 페이지네이션
아홉 개는 인기 순이 아니라 리스트 하나가 실제로 겪는 순서로 묶었습니다. 먼저 항목 하나를 다루는 손동작(01·02 스와이프, 03 당겨서 새로고침), 다음으로 항목 여러 개를 한 번에 다루는 손동작(04·05 정렬, 06 고정 보기), 마지막으로 항목이 너무 많아지면 필요한 것들(07 로딩·08 일괄 선택·09 페이지 나누기) 순서입니다.
01스와이프 삭제 리빌
리스트 행을 왼쪽으로 밀면 빨간 삭제 버튼이 뒤에서 드러나고, 절반 넘게 밀면 행 전체가 자동으로 사라집니다. 버튼은 항상 DOM에 있어 탭이나 Delete 키로도 똑같이 지울 수 있습니다.
// pointermove 로 --dx 를 직접 놓고, 두 임계값으로 나눈다
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(); } // 임계값 넘으면 삭제
else content.style.transform = dx < -44 ? 'translateX(-88px)' : '';
}
02스와이프 다중 액션
더 밀수록 즐겨찾기·보관·삭제 버튼이 순서대로 나타나 나란히 쌓입니다. 세 버튼은 전부 진짜 <button>이라 포커스가 닿으면 키보드로도 하나씩 누를 수 있습니다.
.action {
opacity: 0;
transform: translateY(8px);
transition: opacity .22s, transform .22s;
}
// 콘텐츠에 포커스가 들어오면(탭 이동 포함) 세 버튼이 순서대로 나타난다
.row:focus-within .action { opacity: 1; transform: translateY(0); }
.action--fav { animation-delay: 0ms; }
.action--archive { animation-delay: 80ms; }
.action--del { animation-delay: 160ms; }
03당겨서 새로고침
리스트 맨 위에서 아래로 당기면 고무줄처럼 저항하며 늘어나다가, 손을 떼면 스피너가 돌면서 새로고침됩니다. 새로고침 버튼을 눌러도 같은 동작이 그대로 일어나 제스처를 못 쓰는 사람도 쓸 수 있습니다.
function end() {
if (dy > 44) refresh(); // 절반 이상 당겼으면 새로고침
else body.style.transform = ''; // 아니면 튕겨서 되돌아간다
}
function refresh() {
body.style.transform = 'translateY(56px)';
setTimeout(function () { body.style.transform = ''; }, 900);
}
04드래그 핸들 순서 변경
행 왼쪽의 점 여섯 개 핸들을 잡고 위아래로 끌면 지나가는 행들이 자리를 비켜주고 순서가 바뀝니다. 마우스가 없어도 핸들에 포커스를 두고 방향키만으로 순서를 바꿀 수 있습니다.
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 }); // 부모(홈) 페이지가 강제 스크롤되지 않게
});
05클릭 정렬 테이블 헤더
열 제목을 클릭하면 화살표가 뒤집히며 그 열 기준으로 오름차순·내림차순이 번갈아 정렬됩니다. 정렬 상태는 aria-sort로 스크린 리더에도 그대로 전달됩니다.
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); });
});
06고정 헤더 + 첫 열
표를 가로세로로 스크롤해도 맨 위 헤더 행과 맨 왼쪽 열은 화면에 그대로 붙어 따라옵니다. position: sticky에 top과 left를 같이 주고, 두 조건이 겹치는 모서리 칸만 z-index를 한 단 더 올리면 됩니다.
// 헤더 행 — 세로로 스크롤해도 위에 붙어 있는다
thead th {
position: sticky;
top: 0;
z-index: 2;
}
// 첫 열 — 가로로 스크롤해도 왼쪽에 붙어 있는다
tbody th[scope="row"] {
position: sticky;
left: 0;
z-index: 1;
}
// 헤더+첫 열이 겹치는 모서리 칸만 z-index 를 한 단 더 올린다
.corner { position: sticky; top: 0; left: 0; z-index: 3; }
07스켈레톤 로딩 리스트
데이터가 오기 전 회색 막대 행 여러 개가 좌우로 빛을 흘리며, 곧 콘텐츠가 채워질 자리를 미리 보여줍니다. 밝은 띠와 바탕의 대비를 크게 둘수록 스크린샷이나 gif에서도 훑는 게 또렷하게 보입니다.
.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; }
}
08체크박스 다중 선택 툴바
행 체크박스를 하나라도 고르면 화면 아래에서 삭제·이동 같은 일괄 작업 툴바가 슬라이드로 올라옵니다. 일부만 선택됐을 땐 전체 선택 체크박스가 indeterminate 상태로 바뀌어 "다 선택된 건 아니다"를 눈으로 보여줍니다.
function sync() {
var n = items.filter(function (c) { return c.checked; }).length;
all.checked = n === items.length;
all.indeterminate = n > 0 && n < items.length; // 일부만 선택된 상태를 따로 표시
list.classList.toggle('has-selection', n > 0); // 툴바 슬라이드 트리거
}
09숫자 페이지네이션
페이지 번호 버튼이 나란히 있고, 페이지가 많아지면 가운데가 점 세 개로 줄어들며 현재 페이지만 강조됩니다. 8페이지짜리 데모라 항상 첫 페이지·마지막 페이지·현재 페이지 주변만 번호로 남습니다.
function pages(total, current) {
var out = [];
for (var p = 1; p <= total; p++) {
// 첫·마지막 페이지거나 현재 페이지 바로 옆이면 번호를 남긴다
if (p === 1 || p === total || Math.abs(p - current) <= 1) {
out.push(p);
} else if (out[out.length - 1] !== '...') {
out.push('...'); // 번호가 끊기는 연속 구간은 점 하나로 압축한다
}
}
return out;
}
리스트 스와이프, 어디서 깨지는가
이 아홉 개 중 01·02번은 둘 다 드래그가 시작되는 순간 setPointerCapture를 걸어 둡니다. 이 한 줄을 실수로 빼먹으면(코드 리뷰에서도 흔히 놓치고 지나가는 부분입니다) 손가락이 버튼 밖으로 살짝만 나가도 pointermove가 끊겨 스와이프가 중간에 멈춰버리고, 왜 갑자기 안 움직이는지 사용자도 개발자도 이유를 알 수 없게 됩니다. 두 번째 함정은 08번 툴바를 만들면서 실제로 겪은 것입니다 — 체크된 항목이 없을 땐 화면 밖에 완전히 숨어 있어야 하는 요소인데, 반응형 검사 차원에서 이 480×300 데모를 폰 화면에 가까운 320px 폭으로 다시 렌더링해보니 overflow: hidden으로 감싸기 전에는 document.documentElement.scrollHeight가 뷰포트보다 커져 "화면엔 아무 변화가 없는데 페이지 자체는 늘어나 있는" 상태였습니다. 지금 이 zip에 들어 있는 코드는 그 뒤로 리스트 전체를 overflow: hidden으로 감싸 고친 버전입니다. 이 아홉 개 파일의 압축 비밀번호는 kjj4ftra이고, 압축을 풀면 vanilla·react 폴더에서 같은 구조를 직접 비교해 볼 수 있습니다.
접근성 — 아홉 개가 reduced-motion에서 하는 일
prefers-reduced-motion: reduce가 켜진 사람에게 01~05·08번은 transition: none으로 전환 시간만 없애, 스와이프·정렬·고정 같은 결과 자체는 그대로 일어나되 순간적으로 바뀝니다. 06번(고정 헤더)은 애초에 없앨 transition이 없어서, 자동 재생 데모의 왕복 스크롤만 멈추고 실제 헤더·첫 열 고정은 그대로 작동합니다. 07번(스켈레톤)만 다르게 처리했는데, 반복되는 shimmer 애니메이션 자체를 완전히 끄지 않고 대신 주기를 3.2초로 늘려 깜빡임을 크게 줄입니다 — 로딩 중이라는 신호는 남기되 눈을 피로하게 하는 속도는 뺀 것입니다. 09번(페이지네이션)은 색이 바뀌는 전환만 있고 원래도 순간적인 상태 전환이라 별도로 줄일 애니메이션이 없습니다. 모든 항목은 포커스 스타일이 살아 있어 마우스 없이 Tab·화살표 키만으로도 끝까지 조작할 수 있습니다.
다른 리스트·표 주제는 실전 UI 카테고리에 모여 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다. position: sticky가 스크롤 컨테이너를 어떻게 고르는지는 MDN position 문서에, 포인터 드래그의 setPointerCapture는 MDN Pointer events 문서에 정리돼 있습니다.
FAQ — 자주 묻는 질문
스와이프 삭제는 라이브러리 없이 정말 되나요?
네. 여기 01·02번은 pointerdown·pointermove·pointerup 세 이벤트와 transform: translateX()만으로 만들었고, 별도 제스처 라이브러리를 쓰지 않습니다.
왜 top/left 대신 transform으로 움직이나요?
top·left·height 값을 바꾸면 브라우저가 매 프레임 레이아웃을 새로 계산하느라 드래그·스와이프 중에 뚝뚝 끊길 수 있습니다. transform과 opacity만 건드리면 레이아웃 계산 없이 합성 레이어만 갱신되어 손가락을 따라가는 움직임이 훨씬 매끄럽습니다.
vanilla와 react 코드가 다른가요?
움직임 자체는 같습니다. vanilla 데모는 is-demo 클래스로 반복 재생하도록 만들어 뒀고, React 컴포넌트는 실제 pointer·click 이벤트에만 반응하도록 useState·useRef로 옮겨 뒀습니다.