스와이프 삭제 리스트 UI 9가지 — 페이지네이션까지
스와이프 삭제는 리스트 항목을 옆으로 밀면 뒤에 숨어 있던 삭제 버튼이 드러나는 동작으로, 인스타·배민 앱에서 본 그 동작입니다. 이 글은 스와이프 삭제를 포함해 당겨서 새로고침·드래그 정렬·고정 헤더·페이지네이션까지 리스트 인터랙션 아홉 가지를 실제 동작하는 코드로 정리합니다.
01 swipe-actions-delete-reveal
02 swipe-actions-multi-action-stack
03 pull-to-refresh-elastic-spinner
04 sortable-table-drag-handle-reorder
05 sortable-table-header-click-sort
06 sticky-columns-header-freeze
07 skeleton-list-shimmer-rows
08 bulk-select-checkbox-toolbar
09 pagination-numbered-ellipsis
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 스와이프 삭제 리빌
- 02 스와이프 다중 액션
- 03 당겨서 새로고침
- 04 드래그 핸들 순서 변경
- 05 클릭 정렬 테이블 헤더
- 06 고정 헤더 + 첫 열
- 07 스켈레톤 로딩 리스트
- 08 체크박스 다중 선택 툴바
- 09 숫자 페이지네이션
아홉 개는 인기 순이 아니라 리스트 하나가 실제로 겪는 순서로 묶었습니다. 먼저 항목 하나를 다루는 손동작(01·02 스와이프, 03 당겨서 새로고침), 다음으로 항목 여러 개를 한 번에 다루는 손동작(04·05 정렬, 06 테이블 헤더 고정), 마지막으로 항목이 너무 많아지면 필요한 것들(07 로딩·08 일괄 선택·09 페이지 나누기) 순서입니다. 이 목록의 항목을 눌러 펼치는 쪽은 아코디언 리스트 UI에 있고, 두 장의 사진을 손잡이로 비교하는 화면은 이미지 비교 슬라이더 UI 쪽입니다.
01스와이프 삭제 리빌
무엇이 움직이나
리스트 행을 왼쪽으로 밀면 빨간 삭제 버튼이 뒤에서 드러나고, 절반 넘게 밀면 행 전체가 자동으로 사라집니다.

<main class="stage">
<ul class="swipe-list" aria-label="받은 메일함">
<li class="swipe-row is-demo">
<button class="swipe-row__delete" type="button">삭제</button>
<div class="swipe-row__content" tabindex="0">
<span class="swipe-row__title">주간 회의 안내</span>
<span class="swipe-row__desc">왼쪽으로 밀어보세요</span>
</div>
</li>
<li class="swipe-row">
<button class="swipe-row__delete" type="button">삭제</button>
<div class="swipe-row__content" tabindex="0">
<span class="swipe-row__title">디자인 피드백</span>
<span class="swipe-row__desc">어제 18:20</span>
</div>
</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 콘텐츠가 밀리는 시간
$easing: $ease-out;
$color: $error; // 삭제 버튼 색
@include stage($stage-ink);
.swipe-list {
list-style: none;
margin: 0;
padding: 0;
width: min(280px, 82vw);
display: flex;
flex-direction: column;
gap: $sp-3;
}
.swipe-row {
position: relative;
overflow: hidden;
border-radius: $r-card;
transition: height $duration $easing;
}
.swipe-row__delete {
position: absolute;
inset: 0 0 0 auto;
width: 88px;
border: 0;
border-radius: $r-card;
background: $color;
color: $stage-paper;
font-weight: 700;
font-size: 14px;
font-family: inherit;
cursor: pointer;
}
.swipe-row__content {
position: relative;
display: flex;
flex-direction: column;
gap: $sp-1;
padding: $sp-4;
border-radius: $r-card;
background: $stage-paper;
transform: translateX(0);
transition: transform $duration $easing;
cursor: grab;
touch-action: pan-y;
}
.swipe-row__content:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.swipe-row__title { display: block; font-weight: 700; font-size: 14px; color: $stage-ink; }
.swipe-row__desc { display: block; margin-top: $sp-1; font-size: 12px; color: rgba(23, 20, 26, .6); }
.swipe-row.is-demo .swipe-row__content { animation: swipe-loop $dur-loop $easing infinite; }
@keyframes swipe-loop {
0%, 15% { transform: translateX(0); }
45%, 65% { transform: translateX(-88px); }
90%, 100% { transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
.swipe-row__content { transition: none; }
.swipe-row { transition: none; }
.swipe-row.is-demo .swipe-row__content { animation: none; transform: translateX(-88px); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.swipe-list {
list-style: none;
margin: 0;
padding: 0;
width: min(280px, 82vw);
display: flex;
flex-direction: column;
gap: 12px;
}
.swipe-row {
position: relative;
overflow: hidden;
border-radius: 16px;
transition: height 220ms ease-out;
}
.swipe-row__delete {
position: absolute;
inset: 0 0 0 auto;
width: 88px;
border: 0;
border-radius: 16px;
background: #c2361a;
color: #ffffff;
font-weight: 700;
font-size: 14px;
font-family: inherit;
cursor: pointer;
}
.swipe-row__content {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px;
border-radius: 16px;
background: #ffffff;
transform: translateX(0);
transition: transform 220ms ease-out;
cursor: grab;
touch-action: pan-y;
}
.swipe-row__content:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.swipe-row__title {
display: block;
font-weight: 700;
font-size: 14px;
color: #17141a;
}
.swipe-row__desc {
display: block;
margin-top: 4px;
font-size: 12px;
color: rgba(23, 20, 26, 0.6);
}
.swipe-row.is-demo .swipe-row__content {
animation: swipe-loop 2s ease-out infinite;
}
@keyframes swipe-loop {
0%, 15% {
transform: translateX(0);
}
45%, 65% {
transform: translateX(-88px);
}
90%, 100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.swipe-row__content {
transition: none;
}
.swipe-row {
transition: none;
}
.swipe-row.is-demo .swipe-row__content {
animation: none;
transform: translateX(-88px);
}
}(function () {
var OPEN = 88, DEL = 140;
document.querySelectorAll('.swipe-row').forEach(function (row) {
var content = row.querySelector('.swipe-row__content');
var del = row.querySelector('.swipe-row__delete');
var startX = 0, dx = 0, dragging = false;
function remove() {
row.style.height = row.offsetHeight + 'px';
requestAnimationFrame(function () {
row.classList.add('is-leaving');
row.style.height = '0px';
});
row.addEventListener('transitionend', function h(e) {
if (e.propertyName === 'height') { row.removeEventListener('transitionend', h); row.remove(); }
});
}
content.addEventListener('pointerdown', function (e) {
row.classList.remove('is-demo');
dragging = true; startX = e.clientX; content.setPointerCapture(e.pointerId);
});
content.addEventListener('pointermove', function (e) {
if (!dragging) return;
dx = Math.min(0, Math.max(-DEL - 30, e.clientX - startX));
content.style.transform = 'translateX(' + dx + 'px)';
});
function end() {
if (!dragging) return;
dragging = false;
if (dx < -DEL) { remove(); return; }
content.style.transform = dx < -OPEN / 2 ? 'translateX(-' + OPEN + 'px)' : '';
dx = 0;
}
content.addEventListener('pointerup', end);
content.addEventListener('pointercancel', end);
content.addEventListener('keydown', function (e) {
if (e.key === 'Delete' || e.key === 'Backspace') { row.classList.remove('is-demo'); remove(); }
});
del.addEventListener('click', function () { remove(); });
});
})();import { useRef, useState, type CSSProperties, type PointerEvent } from "react";
import styles from "./SwipeDeleteReveal.module.scss";
export type SwipeDeleteRevealProps = {
title: string;
desc: string;
/** 콘텐츠가 밀리는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 삭제 버튼 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const OPEN = 88;
const DEL = 140;
/** pointer 드래그로 --dx 를 직접 놓고, 임계값을 넘기면 행을 지운다 */
export default function SwipeDeleteReveal({
title,
desc,
duration = 220,
easing = "ease-out",
color = "#c2361a",
}: SwipeDeleteRevealProps) {
const [removed, setRemoved] = useState(false);
const [dx, setDx] = useState(0);
const dragging = useRef(false);
const startX = useRef(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
if (removed) return null;
const down = (e: PointerEvent<HTMLDivElement>) => {
dragging.current = true;
startX.current = e.clientX;
e.currentTarget.setPointerCapture(e.pointerId);
};
const move = (e: PointerEvent<HTMLDivElement>) => {
if (!dragging.current) return;
setDx(Math.min(0, Math.max(-DEL - 30, e.clientX - (startX.current ?? 0))));
};
const up = () => {
if (!dragging.current) return;
dragging.current = false;
if (dx < -DEL) { setRemoved(true); return; }
setDx(dx < -OPEN / 2 ? -OPEN : 0);
};
const key = (e: any) => {
if (e.key === "Delete" || e.key === "Backspace") setRemoved(true);
};
return (
<li className={styles.row} style={vars}>
<button className={styles.del} type="button" onClick={() => setRemoved(true)}>삭제</button>
<div
className={styles.content}
tabIndex={0}
style={{ transform: `translateX(${dx}px)` }}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
onKeyDown={key}
>
<span className={styles.title}>{title}</span>
<span className={styles.desc}>{desc}</span>
</div>
</li>
);
}01 · 스와이프 삭제 리빌 — 측정 누적면적 6.517% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
버튼은 항상 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스와이프 다중 액션
무엇이 움직이나
더 밀수록 즐겨찾기·보관·삭제 버튼이 순서대로 나타나 나란히 쌓입니다.

<main class="stage">
<ul class="ma-list">
<li class="ma-row is-demo">
<div class="ma-row__actions">
<button class="ma-row__action ma-row__action--fav" type="button" aria-pressed="false">★</button>
<button class="ma-row__action ma-row__action--archive" type="button">보관</button>
<button class="ma-row__action ma-row__action--del" type="button">삭제</button>
</div>
<div class="ma-row__content" tabindex="0">
<span class="ma-row__title">새 댓글 3개</span>
<span class="ma-row__desc">게시글에 새 댓글이 달렸어요</span>
</div>
</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 콘텐츠가 밀리는 시간
$easing: $ease-pop;
$color: $subject-blue; // 보관 버튼 색
@include stage($stage-yellow);
.ma-list { list-style: none; margin: 0; padding: 0; width: min(280px, 82vw); }
.ma-row {
position: relative;
overflow: hidden;
border-radius: $r-card;
transition: height $duration $easing, opacity $duration $easing;
}
.ma-row__actions {
position: absolute;
inset: 0 0 0 auto;
display: flex;
width: 198px;
}
.ma-row__action {
flex: 1 0 66px;
border: 0;
color: $stage-paper;
font-weight: 700;
font-size: 13px;
font-family: inherit;
cursor: pointer;
opacity: 0;
transform: translateY(8px);
transition: opacity $duration $easing, transform $duration $easing, background-color $duration $easing;
}
.ma-row__action--fav { background: $stage-ink; }
.ma-row__action--fav.is-on { background: $stage-yellow; color: $stage-ink; }
.ma-row__action--archive { background: $color; }
.ma-row__action--del { background: $error; }
.ma-row__content {
position: relative;
display: flex;
flex-direction: column;
gap: $sp-1;
padding: $sp-4;
border-radius: $r-card;
background: $stage-paper;
transform: translateX(0);
transition: transform $duration $easing;
cursor: grab;
touch-action: pan-y;
}
.ma-row__content:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.ma-row:focus-within .ma-row__action { opacity: 1; transform: translateY(0); }
.ma-row__title { display: block; font-weight: 700; font-size: 14px; color: $stage-ink; }
.ma-row__desc { display: block; margin-top: $sp-1; font-size: 12px; color: rgba(23, 20, 26, .6); }
.ma-row.is-demo .ma-row__content { animation: ma-content-loop $dur-loop $easing infinite; }
.ma-row.is-demo .ma-row__action { animation: ma-action-loop $dur-loop $easing infinite; }
.ma-row.is-demo .ma-row__action--fav { animation-delay: 0ms; }
.ma-row.is-demo .ma-row__action--archive { animation-delay: 80ms; }
.ma-row.is-demo .ma-row__action--del { animation-delay: 160ms; }
@keyframes ma-content-loop {
0%, 15% { transform: translateX(0); }
50%, 70% { transform: translateX(-198px); }
95%, 100% { transform: translateX(0); }
}
@keyframes ma-action-loop {
0%, 15% { opacity: 0; transform: translateY(8px); }
50%, 70% { opacity: 1; transform: translateY(0); }
95%, 100% { opacity: 0; transform: translateY(8px); }
}
@media (prefers-reduced-motion: reduce) {
.ma-row__content, .ma-row__action, .ma-row { transition: none; }
.ma-row.is-demo .ma-row__content { animation: none; transform: translateX(-198px); }
.ma-row.is-demo .ma-row__action { animation: none; opacity: 1; transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ma-list {
list-style: none;
margin: 0;
padding: 0;
width: min(280px, 82vw);
}
.ma-row {
position: relative;
overflow: hidden;
border-radius: 16px;
transition: height 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ma-row__actions {
position: absolute;
inset: 0 0 0 auto;
display: flex;
width: 198px;
}
.ma-row__action {
flex: 1 0 66px;
border: 0;
color: #ffffff;
font-weight: 700;
font-size: 13px;
font-family: inherit;
cursor: pointer;
opacity: 0;
transform: translateY(8px);
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), background-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ma-row__action--fav {
background: #17141a;
}
.ma-row__action--fav.is-on {
background: #ffd23f;
color: #17141a;
}
.ma-row__action--archive {
background: #2f6df6;
}
.ma-row__action--del {
background: #c2361a;
}
.ma-row__content {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px;
border-radius: 16px;
background: #ffffff;
transform: translateX(0);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
cursor: grab;
touch-action: pan-y;
}
.ma-row__content:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ma-row:focus-within .ma-row__action {
opacity: 1;
transform: translateY(0);
}
.ma-row__title {
display: block;
font-weight: 700;
font-size: 14px;
color: #17141a;
}
.ma-row__desc {
display: block;
margin-top: 4px;
font-size: 12px;
color: rgba(23, 20, 26, 0.6);
}
.ma-row.is-demo .ma-row__content {
animation: ma-content-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.ma-row.is-demo .ma-row__action {
animation: ma-action-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.ma-row.is-demo .ma-row__action--fav {
animation-delay: 0ms;
}
.ma-row.is-demo .ma-row__action--archive {
animation-delay: 80ms;
}
.ma-row.is-demo .ma-row__action--del {
animation-delay: 160ms;
}
@keyframes ma-content-loop {
0%, 15% {
transform: translateX(0);
}
50%, 70% {
transform: translateX(-198px);
}
95%, 100% {
transform: translateX(0);
}
}
@keyframes ma-action-loop {
0%, 15% {
opacity: 0;
transform: translateY(8px);
}
50%, 70% {
opacity: 1;
transform: translateY(0);
}
95%, 100% {
opacity: 0;
transform: translateY(8px);
}
}
@media (prefers-reduced-motion: reduce) {
.ma-row__content, .ma-row__action, .ma-row {
transition: none;
}
.ma-row.is-demo .ma-row__content {
animation: none;
transform: translateX(-198px);
}
.ma-row.is-demo .ma-row__action {
animation: none;
opacity: 1;
transform: translateY(0);
}
}(function () {
var OPEN = 198;
document.querySelectorAll('.ma-row').forEach(function (row) {
var content = row.querySelector('.ma-row__content');
var fav = row.querySelector('.ma-row__action--fav');
var startX = 0, dx = 0, dragging = false;
function collapse() {
row.style.height = row.offsetHeight + 'px';
requestAnimationFrame(function () { row.style.height = '0px'; row.style.opacity = '0'; });
row.addEventListener('transitionend', function h(e) {
if (e.propertyName === 'height') { row.removeEventListener('transitionend', h); row.remove(); }
});
}
content.addEventListener('pointerdown', function (e) {
row.classList.remove('is-demo');
dragging = true; startX = e.clientX; content.setPointerCapture(e.pointerId);
});
content.addEventListener('pointermove', function (e) {
if (!dragging) return;
dx = Math.min(0, Math.max(-OPEN, e.clientX - startX));
content.style.transform = 'translateX(' + dx + 'px)';
});
function end() {
if (!dragging) return;
dragging = false;
content.style.transform = dx < -OPEN / 2 ? 'translateX(-' + OPEN + 'px)' : '';
dx = 0;
}
content.addEventListener('pointerup', end);
content.addEventListener('pointercancel', end);
fav.addEventListener('click', function () {
var on = fav.getAttribute('aria-pressed') === 'true';
fav.setAttribute('aria-pressed', String(!on));
fav.classList.toggle('is-on', !on);
});
row.querySelector('.ma-row__action--archive').addEventListener('click', collapse);
row.querySelector('.ma-row__action--del').addEventListener('click', collapse);
});
})();import { useRef, useState, type CSSProperties, type PointerEvent } from "react";
import styles from "./SwipeMultiActionStack.module.scss";
export type SwipeMultiActionStackProps = {
title: string;
desc: string;
/** 콘텐츠가 밀리는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 보관 버튼 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const OPEN = 198;
export default function SwipeMultiActionStack({
title,
desc,
duration = 220,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#2f6df6",
}: SwipeMultiActionStackProps) {
const [dx, setDx] = useState(0);
const [fav, setFav] = useState(false);
const [gone, setGone] = useState(false);
const dragging = useRef(false);
const startX = useRef(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
if (gone) return null;
const down = (e: PointerEvent<HTMLDivElement>) => {
dragging.current = true;
startX.current = e.clientX;
e.currentTarget.setPointerCapture(e.pointerId);
};
const move = (e: PointerEvent<HTMLDivElement>) => {
if (!dragging.current) return;
setDx(Math.min(0, Math.max(-OPEN, e.clientX - (startX.current ?? 0))));
};
const up = () => {
if (!dragging.current) return;
dragging.current = false;
setDx(dx < -OPEN / 2 ? -OPEN : 0);
};
return (
<li className={styles.row} style={vars}>
<div className={styles.actions} style={{ opacity: dx < 0 ? 1 : undefined }}>
<button className={styles.fav} type="button" aria-pressed={fav} onClick={() => setFav((v) => !v)}>★</button>
<button className={styles.archive} type="button" onClick={() => setGone(true)}>보관</button>
<button className={styles.delBtn} type="button" onClick={() => setGone(true)}>삭제</button>
</div>
<div
className={styles.content}
tabIndex={0}
style={{ transform: `translateX(${dx}px)` }}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
>
<span className={styles.title}>{title}</span>
<span className={styles.desc}>{desc}</span>
</div>
</li>
);
}02 · 스와이프 다중 액션 — 측정 누적면적 12.767% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
세 버튼은 전부 진짜 <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당겨서 새로고침
무엇이 움직이나
리스트 맨 위에서 아래로 당기면 고무줄처럼 저항하며 늘어나다가, 손을 떼면 스피너가 돌면서 새로고침됩니다.

<main class="stage">
<div class="ptr is-demo">
<div class="ptr__indicator" aria-hidden="true"><span class="ptr__spinner"></span></div>
<div class="ptr__body">
<div class="ptr__head">
<span class="ptr__title">받은 피드</span>
<button class="ptr__refresh" type="button">새로고침</button>
</div>
<ul class="ptr__list">
<li>오늘의 추천 3개</li>
<li>친구 활동 2건</li>
</ul>
<p class="ptr__status" aria-live="polite">방금 새로고침함</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 당긴 만큼 늘어나는 시간
$easing: $ease-spring;
$color: $subject-blue; // 스피너 색
@include stage($stage-orange);
.ptr {
position: relative;
width: min(240px, 78vw);
overflow: hidden;
border-radius: $r-card;
}
.ptr__indicator {
position: absolute;
top: $sp-2;
left: 0;
right: 0;
display: flex;
justify-content: center;
}
.ptr__spinner {
width: 24px;
height: 24px;
border-radius: 50%;
border: 3px solid rgba($stage-paper, .4);
border-top-color: $color;
opacity: 0;
transition: opacity $duration $easing;
}
.ptr__body {
position: relative;
padding: $sp-4;
border-radius: $r-card;
background: $stage-paper;
transform: translateY(0);
transition: transform $duration $easing;
touch-action: pan-x;
cursor: grab;
}
.ptr__head { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.ptr__title { font-weight: 700; font-size: 14px; color: $stage-ink; }
.ptr__refresh {
border: 0;
border-radius: $r-pill;
padding: $sp-1 $sp-3;
background: $color;
color: $stage-paper;
font-size: 11px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
}
.ptr__list { list-style: none; margin: $sp-3 0 0; padding: 0; font-size: 12px; color: rgba(23, 20, 26, .7); display: flex; flex-direction: column; gap: $sp-1; }
.ptr__status { margin: $sp-2 0 0; font-size: 11px; color: rgba(23, 20, 26, .72); }
.ptr.is-refreshing .ptr__spinner { opacity: 1; animation: ptr-spin .7s linear infinite; }
.ptr.is-demo .ptr__body { animation: ptr-loop $dur-loop $easing infinite; }
.ptr.is-demo .ptr__spinner { animation: ptr-spin-loop $dur-loop $easing infinite; }
@keyframes ptr-spin { to { transform: rotate(360deg); } }
@keyframes ptr-loop {
0%, 10% { transform: translateY(0); }
40%, 60% { transform: translateY(56px); }
95%, 100% { transform: translateY(0); }
}
@keyframes ptr-spin-loop {
0%, 10% { opacity: 0; transform: rotate(0deg); }
40%, 60% { opacity: 1; transform: rotate(360deg); }
95%, 100% { opacity: 0; transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.ptr__body, .ptr__spinner { transition: none; }
.ptr.is-demo .ptr__body { animation: none; transform: translateY(56px); }
.ptr.is-demo .ptr__spinner { animation: none; opacity: 1; }
.ptr.is-refreshing .ptr__spinner { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ptr {
position: relative;
width: min(240px, 78vw);
overflow: hidden;
border-radius: 16px;
}
.ptr__indicator {
position: absolute;
top: 8px;
left: 0;
right: 0;
display: flex;
justify-content: center;
}
.ptr__spinner {
width: 24px;
height: 24px;
border-radius: 50%;
border: 3px solid rgba(255, 255, 255, 0.4);
border-top-color: #2f6df6;
opacity: 0;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ptr__body {
position: relative;
padding: 16px;
border-radius: 16px;
background: #ffffff;
transform: translateY(0);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
touch-action: pan-x;
cursor: grab;
}
.ptr__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ptr__title {
font-weight: 700;
font-size: 14px;
color: #17141a;
}
.ptr__refresh {
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #2f6df6;
color: #ffffff;
font-size: 11px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
}
.ptr__list {
list-style: none;
margin: 12px 0 0;
padding: 0;
font-size: 12px;
color: rgba(23, 20, 26, 0.7);
display: flex;
flex-direction: column;
gap: 4px;
}
.ptr__status {
margin: 8px 0 0;
font-size: 11px;
color: rgba(23, 20, 26, 0.72);
}
.ptr.is-refreshing .ptr__spinner {
opacity: 1;
animation: ptr-spin 0.7s linear infinite;
}
.ptr.is-demo .ptr__body {
animation: ptr-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.ptr.is-demo .ptr__spinner {
animation: ptr-spin-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes ptr-spin {
to {
transform: rotate(360deg);
}
}
@keyframes ptr-loop {
0%, 10% {
transform: translateY(0);
}
40%, 60% {
transform: translateY(56px);
}
95%, 100% {
transform: translateY(0);
}
}
@keyframes ptr-spin-loop {
0%, 10% {
opacity: 0;
transform: rotate(0deg);
}
40%, 60% {
opacity: 1;
transform: rotate(360deg);
}
95%, 100% {
opacity: 0;
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ptr__body, .ptr__spinner {
transition: none;
}
.ptr.is-demo .ptr__body {
animation: none;
transform: translateY(56px);
}
.ptr.is-demo .ptr__spinner {
animation: none;
opacity: 1;
}
.ptr.is-refreshing .ptr__spinner {
animation: none;
}
}(function () {
var ptr = document.querySelector('.ptr');
var body = ptr.querySelector('.ptr__body');
var status = ptr.querySelector('.ptr__status');
var startY = 0, dy = 0, dragging = false, refreshing = false;
function refresh() {
if (refreshing) return;
refreshing = true;
ptr.classList.add('is-refreshing');
body.style.transform = 'translateY(56px)';
status.style.visibility = 'hidden';
setTimeout(function () {
refreshing = false;
ptr.classList.remove('is-refreshing');
body.style.transform = '';
status.style.visibility = '';
}, 900);
}
body.addEventListener('pointerdown', function (e) {
ptr.classList.remove('is-demo');
dragging = true; startY = e.clientY; body.setPointerCapture(e.pointerId);
});
body.addEventListener('pointermove', function (e) {
if (!dragging || refreshing) return;
dy = Math.max(0, Math.min(72, (e.clientY - startY) * 0.5));
body.style.transform = 'translateY(' + dy + 'px)';
});
function end() {
if (!dragging || refreshing) { dragging = false; return; }
dragging = false;
if (dy > 44) { refresh(); } else { body.style.transform = ''; }
dy = 0;
}
body.addEventListener('pointerup', end);
body.addEventListener('pointercancel', end);
ptr.querySelector('.ptr__refresh').addEventListener('click', function () {
ptr.classList.remove('is-demo'); refresh();
});
})();import { useRef, useState, type CSSProperties, type PointerEvent } from "react";
import styles from "./PullToRefreshSpinner.module.scss";
export type PullToRefreshSpinnerProps = {
title: string;
onRefresh?: () => void;
/** 당긴 만큼 늘어나는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 스피너 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PullToRefreshSpinner({
title,
onRefresh,
duration = 300,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: PullToRefreshSpinnerProps) {
const [dy, setDy] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const dragging = useRef(false);
const startY = useRef(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const refresh = () => {
if (refreshing) return;
setRefreshing(true);
setDy(56);
onRefresh?.();
setTimeout(() => { setRefreshing(false); setDy(0); }, 900);
};
const down = (e: PointerEvent<HTMLDivElement>) => {
dragging.current = true;
startY.current = e.clientY;
e.currentTarget.setPointerCapture(e.pointerId);
};
const move = (e: PointerEvent<HTMLDivElement>) => {
if (!dragging.current || refreshing) return;
setDy(Math.max(0, Math.min(72, (e.clientY - (startY.current ?? 0)) * 0.5)));
};
const up = () => {
if (!dragging.current || refreshing) { dragging.current = false; return; }
dragging.current = false;
if (dy > 44) refresh(); else setDy(0);
};
return (
<div className={styles.ptr} style={vars}>
<div className={styles.indicator} aria-hidden="true">
<span className={styles.spinner} data-spin={refreshing || undefined} />
</div>
<div
className={styles.body}
style={{ transform: `translateY(${dy}px)` }}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
>
<div className={styles.head}>
<span className={styles.title}>{title}</span>
<button className={styles.refreshBtn} type="button" onClick={refresh}>새로고침</button>
</div>
<p className={styles.status} style={{ visibility: refreshing ? "hidden" : undefined }} aria-live="polite">
방금 새로고침함
</p>
</div>
</div>
);
}03 · 당겨서 새로고침 — 측정 누적면적 14.518% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
새로고침 버튼을 눌러도 같은 동작이 그대로 일어나 제스처를 못 쓰는 사람도 쓸 수 있습니다.
function end() {
if (dy > 44) refresh(); // 절반 이상 당겼으면 새로고침
else body.style.transform = ''; // 아니면 튕겨서 되돌아간다
}
function refresh() {
body.style.transform = 'translateY(56px)';
setTimeout(function () { body.style.transform = ''; }, 900);
}
04드래그 핸들 순서 변경
무엇이 움직이나
행 왼쪽의 점 여섯 개 핸들을 잡고 위아래로 끌면 지나가는 행들이 자리를 비켜주고 순서가 바뀝니다.

<main class="stage">
<ul class="drag-list is-demo" aria-label="처리 순서">
<li class="drag-row">
<button class="drag-row__handle" type="button" aria-label="드래그하거나 화살표 키로 순서 변경">⠿</button>
<span class="drag-row__label">결제 확인</span>
</li>
<li class="drag-row">
<button class="drag-row__handle" type="button" aria-label="드래그하거나 화살표 키로 순서 변경">⠿</button>
<span class="drag-row__label">배송 준비</span>
</li>
<li class="drag-row">
<button class="drag-row__handle" type="button" aria-label="드래그하거나 화살표 키로 순서 변경">⠿</button>
<span class="drag-row__label">리뷰 요청</span>
</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 드래그 중 스냅 시간
$easing: $ease-out;
$color: $subject-blue; // 핸들 색
@include stage($stage-paper);
.drag-list {
position: relative;
list-style: none;
margin: 0;
padding: 0;
width: min(240px, 78vw);
display: flex;
flex-direction: column;
gap: $sp-2;
}
.drag-row {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-3;
border-radius: $r-control;
background: $subject-cream;
box-shadow: $shadow-press;
transition: transform $duration $easing;
&.is-dragging { transition: none; z-index: 2; box-shadow: $shadow-raised; }
}
.drag-row__handle {
flex: 0 0 auto;
width: 28px;
height: 28px;
border: 0;
border-radius: $r-sm;
background: $color;
color: $stage-paper;
font-size: 14px;
line-height: 1;
cursor: grab;
touch-action: none;
}
.drag-row__handle:focus-visible { outline: 2px solid $stage-ink; outline-offset: 2px; }
.drag-row__label { font-weight: 700; font-size: 13px; color: $stage-ink; font-family: inherit; }
.drag-list.is-demo .drag-row:nth-child(1) { animation: drag-a $dur-loop $easing infinite; }
.drag-list.is-demo .drag-row:nth-child(2) { animation: drag-b $dur-loop $easing infinite; }
@keyframes drag-a {
0%, 10% { transform: translateY(0); }
45%, 65% { transform: translateY(60px); }
95%, 100% { transform: translateY(0); }
}
@keyframes drag-b {
0%, 10% { transform: translateY(0); }
45%, 65% { transform: translateY(-60px); }
95%, 100% { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.drag-row { transition: none; }
.drag-list.is-demo .drag-row:nth-child(1),
.drag-list.is-demo .drag-row:nth-child(2) { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.drag-list {
position: relative;
list-style: none;
margin: 0;
padding: 0;
width: min(240px, 78vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.drag-row {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-radius: 12px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms ease-out;
}
.drag-row.is-dragging {
transition: none;
z-index: 2;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.drag-row__handle {
flex: 0 0 auto;
width: 28px;
height: 28px;
border: 0;
border-radius: 8px;
background: #2f6df6;
color: #ffffff;
font-size: 14px;
line-height: 1;
cursor: grab;
touch-action: none;
}
.drag-row__handle:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.drag-row__label {
font-weight: 700;
font-size: 13px;
color: #17141a;
font-family: inherit;
}
.drag-list.is-demo .drag-row:nth-child(1) {
animation: drag-a 2s ease-out infinite;
}
.drag-list.is-demo .drag-row:nth-child(2) {
animation: drag-b 2s ease-out infinite;
}
@keyframes drag-a {
0%, 10% {
transform: translateY(0);
}
45%, 65% {
transform: translateY(60px);
}
95%, 100% {
transform: translateY(0);
}
}
@keyframes drag-b {
0%, 10% {
transform: translateY(0);
}
45%, 65% {
transform: translateY(-60px);
}
95%, 100% {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.drag-row {
transition: none;
}
.drag-list.is-demo .drag-row:nth-child(1),
.drag-list.is-demo .drag-row:nth-child(2) {
animation: none;
}
}(function () {
var list = document.querySelector('.drag-list');
Array.prototype.forEach.call(list.children, function (row) {
var handle = row.querySelector('.drag-row__handle');
var startY = 0, dy = 0, dragging = false;
handle.addEventListener('pointerdown', function (e) {
list.classList.remove('is-demo');
dragging = true; startY = e.clientY; row.classList.add('is-dragging');
handle.setPointerCapture(e.pointerId);
});
handle.addEventListener('pointermove', function (e) {
if (!dragging) return;
dy = e.clientY - startY;
row.style.transform = 'translateY(' + dy + 'px)';
});
function end() {
if (!dragging) return;
dragging = false; row.classList.remove('is-dragging');
var target = dy > 30 ? row.nextElementSibling : dy < -30 ? row.previousElementSibling : null;
row.style.transform = '';
if (target) list.insertBefore(dy > 0 ? target : row, dy > 0 ? row : target);
dy = 0;
}
handle.addEventListener('pointerup', end);
handle.addEventListener('pointercancel', end);
handle.addEventListener('keydown', function (e) {
list.classList.remove('is-demo');
var sib = e.key === 'ArrowDown' ? row.nextElementSibling : e.key === 'ArrowUp' ? row.previousElementSibling : null;
if (!sib) return;
e.preventDefault();
if (e.key === 'ArrowDown') list.insertBefore(sib, row); else list.insertBefore(row, sib);
handle.focus({ preventScroll: true });
});
});
})();import { useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import styles from "./DragHandleReorder.module.scss";
export type DragHandleReorderProps = {
items: string[];
/** 드래그 스냅 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 핸들 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function DragHandleReorder({
items,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: DragHandleReorderProps) {
const [rows, setRows] = useState(items);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dy, setDy] = useState(0);
const startY = useRef(0);
const handles = useRef<{ [label: string]: HTMLButtonElement | null }>({});
const refocusLabel = useRef<string | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 키보드로 순서를 바꾼 뒤에도 같은 핸들에 포커스가 남아 있어야 연속으로 화살표 키를 누를 수 있다
// (vanilla 판의 handle.focus({ preventScroll: true }) 와 같은 목적 — react 는 key 재조정으로도
// 대개 포커스가 유지되지만, 여기서는 명시적으로 한 번 더 확인해 다른 리렌더에 흔들리지 않게 한다)
useEffect(() => {
const label = refocusLabel.current;
if (label) { handles.current![label]?.focus({ preventScroll: true }); refocusLabel.current = null; }
}, [rows]);
const swap = (i: number, j: number, keyboardLabel?: string) => {
if (j < 0 || j >= rows.length) return;
const next = rows.slice();
const t = next[i]; next[i] = next[j]; next[j] = t;
if (keyboardLabel) refocusLabel.current = keyboardLabel;
setRows(next);
};
const down = (i: number) => (e: PointerEvent<HTMLButtonElement>) => {
setDragIndex(i); startY.current = e.clientY;
e.currentTarget.setPointerCapture(e.pointerId);
};
const move = (e: PointerEvent<HTMLButtonElement>) => {
if (dragIndex === null) return;
setDy(e.clientY - (startY.current ?? 0));
};
const up = () => {
if (dragIndex === null) return;
if (dy > 30) swap(dragIndex, dragIndex + 1);
else if (dy < -30) swap(dragIndex, dragIndex - 1);
setDragIndex(null); setDy(0);
};
const key = (i: number, label: string) => (e: any) => {
if (e.key === "ArrowDown") { e.preventDefault(); swap(i, i + 1, label); }
else if (e.key === "ArrowUp") { e.preventDefault(); swap(i, i - 1, label); }
};
return (
<ul className={styles.list} style={vars}>
{rows.map((label, i) => (
<li
key={label}
className={styles.row}
style={dragIndex === i ? { transform: `translateY(${dy}px)`, transition: "none" } : undefined}
>
<button
ref={(el: HTMLButtonElement | null) => { handles.current![label] = el; }}
className={styles.handle}
type="button"
aria-label="드래그하거나 화살표 키로 순서 변경"
onPointerDown={down(i)}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
onKeyDown={key(i, label)}
>⠿</button>
<span className={styles.label}>{label}</span>
</li>
))}
</ul>
);
}04 · 드래그 핸들 순서 변경 — 측정 누적면적 18.702% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
마우스가 없어도 핸들에 포커스를 두고 방향키만으로 순서를 바꿀 수 있습니다.
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클릭 정렬 테이블 헤더
무엇이 움직이나
열 제목을 클릭하면 화살표가 뒤집히며 그 열 기준으로 오름차순·내림차순이 번갈아 정렬됩니다.

<main class="stage">
<table class="sort-table is-demo">
<caption class="sort-table__caption">참가자 점수</caption>
<thead>
<tr>
<th><button class="sort-table__th" type="button" data-key="name" aria-sort="none">이름<span class="sort-table__arrow" aria-hidden="true">▾</span></button></th>
<th><button class="sort-table__th" type="button" data-key="score" aria-sort="descending">점수<span class="sort-table__arrow" aria-hidden="true">▾</span></button></th>
</tr>
</thead>
<tbody>
<tr><td>도윤</td><td data-score="92">92</td></tr>
<tr><td>서연</td><td data-score="88">88</td></tr>
<tr><td>민준</td><td data-score="76">76</td></tr>
</tbody>
</table>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 화살표가 뒤집히는 시간
$easing: $ease-out;
$color: $subject-blue; // 정렬 중인 열 강조 색
@include stage($stage-ink);
.sort-table {
position: relative;
width: min(240px, 78vw);
border-collapse: separate;
border-spacing: 0;
border-radius: $r-card;
overflow: hidden;
background: $stage-paper;
font-size: 13px;
}
.sort-table__caption {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.sort-table th, .sort-table td {
padding: $sp-2 $sp-3;
text-align: left;
}
.sort-table thead th { background: rgba($subject-blue, .1); }
// tbody 행에도 불투명 배경을 줘야 한다 — 안 주면 정렬 데모 루프에서 두 행이 translateY 로 겹칠 때
// 배경이 없는 행 뒤로 다른 행 글자가 그대로 비쳐 보인다 (실측으로 확인)
.sort-table tbody td { background: $stage-paper; }
.sort-table tbody tr:not(:last-child) td { border-bottom: 1px solid rgba(23, 20, 26, .08); }
.sort-table__th {
display: flex;
align-items: center;
gap: $sp-1;
width: 100%;
border: 0;
background: none;
padding: 0;
font-family: inherit;
font-size: inherit;
font-weight: 700;
color: $stage-ink;
cursor: pointer;
}
.sort-table__th:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.sort-table__th[aria-sort="none"] .sort-table__arrow { opacity: .35; transform: rotate(0deg); }
.sort-table__th[aria-sort="descending"] .sort-table__arrow { opacity: 1; transform: rotate(0deg); color: $subject-blue-deep; } // 연파랑 헤더 위 파랑 3.96:1 → deep 5.29:1
.sort-table__th[aria-sort="ascending"] .sort-table__arrow { opacity: 1; transform: rotate(180deg); color: $subject-blue-deep; }
.sort-table__arrow { display: inline-block; font-size: 10px; transition: transform $duration $easing, opacity $duration $easing; }
.sort-table.is-demo tbody tr:nth-child(1) { animation: sort-row-a $dur-loop $easing infinite; }
.sort-table.is-demo tbody tr:nth-child(2) { animation: sort-row-b $dur-loop $easing infinite; }
.sort-table.is-demo [data-key="score"] .sort-table__arrow { animation: sort-arrow-loop $dur-loop $easing infinite; }
@keyframes sort-row-a {
0%, 10% { transform: translateY(0); }
45%, 65% { transform: translateY(37px); }
95%, 100% { transform: translateY(0); }
}
@keyframes sort-row-b {
0%, 10% { transform: translateY(0); }
45%, 65% { transform: translateY(-37px); }
95%, 100% { transform: translateY(0); }
}
@keyframes sort-arrow-loop {
0%, 10% { transform: rotate(0deg); }
50%, 60% { transform: rotate(180deg); }
95%, 100% { transform: rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) {
.sort-table__arrow { transition: none; }
.sort-table.is-demo tbody tr:nth-child(1),
.sort-table.is-demo tbody tr:nth-child(2),
.sort-table.is-demo [data-key="score"] .sort-table__arrow { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sort-table {
position: relative;
width: min(240px, 78vw);
border-collapse: separate;
border-spacing: 0;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
font-size: 13px;
}
.sort-table__caption {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.sort-table th, .sort-table td {
padding: 8px 12px;
text-align: left;
}
.sort-table thead th {
background: rgba(47, 109, 246, 0.1);
}
.sort-table tbody td {
background: #ffffff;
}
.sort-table tbody tr:not(:last-child) td {
border-bottom: 1px solid rgba(23, 20, 26, 0.08);
}
.sort-table__th {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
border: 0;
background: none;
padding: 0;
font-family: inherit;
font-size: inherit;
font-weight: 700;
color: #17141a;
cursor: pointer;
}
.sort-table__th:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.sort-table__th[aria-sort=none] .sort-table__arrow {
opacity: 0.35;
transform: rotate(0deg);
}
.sort-table__th[aria-sort=descending] .sort-table__arrow {
opacity: 1;
transform: rotate(0deg);
color: #0a4fe8;
}
.sort-table__th[aria-sort=ascending] .sort-table__arrow {
opacity: 1;
transform: rotate(180deg);
color: #0a4fe8;
}
.sort-table__arrow {
display: inline-block;
font-size: 10px;
transition: transform 220ms ease-out, opacity 220ms ease-out;
}
.sort-table.is-demo tbody tr:nth-child(1) {
animation: sort-row-a 2s ease-out infinite;
}
.sort-table.is-demo tbody tr:nth-child(2) {
animation: sort-row-b 2s ease-out infinite;
}
.sort-table.is-demo [data-key=score] .sort-table__arrow {
animation: sort-arrow-loop 2s ease-out infinite;
}
@keyframes sort-row-a {
0%, 10% {
transform: translateY(0);
}
45%, 65% {
transform: translateY(37px);
}
95%, 100% {
transform: translateY(0);
}
}
@keyframes sort-row-b {
0%, 10% {
transform: translateY(0);
}
45%, 65% {
transform: translateY(-37px);
}
95%, 100% {
transform: translateY(0);
}
}
@keyframes sort-arrow-loop {
0%, 10% {
transform: rotate(0deg);
}
50%, 60% {
transform: rotate(180deg);
}
95%, 100% {
transform: rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.sort-table__arrow {
transition: none;
}
.sort-table.is-demo tbody tr:nth-child(1),
.sort-table.is-demo tbody tr:nth-child(2),
.sort-table.is-demo [data-key=score] .sort-table__arrow {
animation: none;
}
}(function () {
var table = document.querySelector('.sort-table');
var tbody = table.querySelector('tbody');
var ths = table.querySelectorAll('.sort-table__th');
ths.forEach(function (btn) {
btn.addEventListener('click', function () {
table.classList.remove('is-demo');
var key = btn.dataset.key;
var dir = btn.getAttribute('aria-sort') === 'ascending' ? 'descending' : 'ascending';
ths.forEach(function (b) { b.setAttribute('aria-sort', 'none'); });
btn.setAttribute('aria-sort', dir);
var rows = Array.prototype.slice.call(tbody.rows);
rows.sort(function (a, b) {
var av = key === 'score' ? Number(a.cells[1].dataset.score) : a.cells[0].textContent;
var bv = key === 'score' ? Number(b.cells[1].dataset.score) : b.cells[0].textContent;
var cmp = av > bv ? 1 : av < bv ? -1 : 0;
return dir === 'ascending' ? cmp : -cmp;
});
rows.forEach(function (r) { tbody.appendChild(r); });
});
});
})();import { useState, type CSSProperties } from "react";
import styles from "./SortableHeaderClick.module.scss";
export type Row = { name: string; score: number };
export type SortableHeaderClickProps = {
rows: Row[];
/** 화살표가 뒤집히는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 정렬 중인 열 강조 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Key = "name" | "score";
type Dir = "none" | "ascending" | "descending";
export default function SortableHeaderClick({
rows,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: SortableHeaderClickProps) {
const [key, setKey] = useState<Key>("score");
const [dir, setDir] = useState<Dir>("descending");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const sorted = rows.slice().sort((a, b) => {
const cmp = a[key] > b[key] ? 1 : a[key] < b[key] ? -1 : 0;
return dir === "ascending" ? cmp : -cmp;
});
const click = (k: Key) => {
setDir(k === key && dir === "ascending" ? "descending" : "ascending");
setKey(k);
};
const sortOf = (k: Key): Dir => (k === key ? dir : "none");
return (
<table className={styles.table} style={vars}>
<caption className={styles.caption}>참가자 점수</caption>
<thead>
<tr>
<th>
<button className={styles.th} type="button" aria-sort={sortOf("name")} onClick={() => click("name")}>
이름<span className={styles.arrow} aria-hidden="true">▾</span>
</button>
</th>
<th>
<button className={styles.th} type="button" aria-sort={sortOf("score")} onClick={() => click("score")}>
점수<span className={styles.arrow} aria-hidden="true">▾</span>
</button>
</th>
</tr>
</thead>
<tbody>
{sorted.map((r) => (
<tr key={r.name}>
<td>{r.name}</td>
<td>{r.score}</td>
</tr>
))}
</tbody>
</table>
);
}05 · 클릭 정렬 테이블 헤더 — 측정 누적면적 3.665% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
정렬 상태는 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고정 헤더 + 첫 열
무엇이 움직이나
표를 가로세로로 스크롤해도 맨 위 헤더 행과 맨 왼쪽 열은 화면에 그대로 붙어 따라옵니다.

<main class="stage">
<div class="freeze-wrap is-demo" tabindex="0" aria-label="가격 비교표. 화살표 키나 드래그로 스크롤">
<table class="freeze-table">
<thead>
<tr>
<th class="freeze-table__corner">플랜</th>
<th>1월</th><th>2월</th><th>3월</th><th>4월</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">베이직</th><td>9,900</td><td>9,900</td><td>9,900</td><td>9,900</td></tr>
<tr><th scope="row">프로</th><td>19,900</td><td>19,900</td><td>19,900</td><td>19,900</td></tr>
<tr><th scope="row">팀</th><td>39,900</td><td>39,900</td><td>39,900</td><td>39,900</td></tr>
<tr><th scope="row">엔터프라이즈</th><td>문의</td><td>문의</td><td>문의</td><td>문의</td></tr>
</tbody>
</table>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 스크롤 자동 시연 왕복 시간(스크립트 값과 맞춤)
$easing: $ease-out;
$color: $subject-blue; // 헤더·첫 열 배경
@include stage($stage-yellow);
.freeze-wrap {
width: 220px;
height: 132px;
overflow: auto;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
}
.freeze-wrap:focus-visible { outline: 2px solid $stage-ink; outline-offset: 2px; }
.freeze-table {
border-collapse: separate;
border-spacing: 0;
font-size: 12px;
color: $stage-ink;
}
.freeze-table th, .freeze-table td {
padding: $sp-2 $sp-3;
min-width: 72px;
white-space: nowrap;
text-align: right;
background: $stage-paper;
}
// 스티키 셀은 스크롤되는 내용 위로 겹쳐 그려지므로 배경이 완전 불투명해야 한다.
// rgba(..., .16) 처럼 반투명으로 두면 아래로 스크롤되는 글자가 비쳐 보여 겹침(고스팅)이 생긴다 — 실측으로 확인
.freeze-table thead th {
position: sticky;
top: 0;
z-index: 2;
background: #dee8fe; // $subject-blue 를 흰 배경에 16% 섞은 불투명 값
text-align: right;
font-weight: 700;
}
.freeze-table tbody th[scope="row"] {
position: sticky;
left: 0;
z-index: 1;
text-align: left;
font-weight: 700;
background: $stage-paper;
}
.freeze-table__corner {
position: sticky;
top: 0;
left: 0;
z-index: 3;
text-align: left !important;
background: #c5d6fc !important; // $subject-blue 를 28% 섞은 불투명 값
}
// 여기도 th[scope="row"]는 스티키(왼쪽 고정)라 불투명해야 한다 — td는 스크롤에 그냥 같이 움직이니 상관없다
.freeze-table tbody tr:nth-child(even) td { background: rgba(23, 20, 26, .04); }
.freeze-table tbody tr:nth-child(even) th[scope="row"] { background: #f6f6f6; }
@media (prefers-reduced-motion: reduce) {
.freeze-wrap { scroll-behavior: auto; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.freeze-wrap {
width: 220px;
height: 132px;
overflow: auto;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.freeze-wrap:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.freeze-table {
border-collapse: separate;
border-spacing: 0;
font-size: 12px;
color: #17141a;
}
.freeze-table th, .freeze-table td {
padding: 8px 12px;
min-width: 72px;
white-space: nowrap;
text-align: right;
background: #ffffff;
}
.freeze-table thead th {
position: sticky;
top: 0;
z-index: 2;
background: #dee8fe;
text-align: right;
font-weight: 700;
}
.freeze-table tbody th[scope=row] {
position: sticky;
left: 0;
z-index: 1;
text-align: left;
font-weight: 700;
background: #ffffff;
}
.freeze-table__corner {
position: sticky;
top: 0;
left: 0;
z-index: 3;
text-align: left !important;
background: #c5d6fc !important;
}
.freeze-table tbody tr:nth-child(even) td {
background: rgba(23, 20, 26, 0.04);
}
.freeze-table tbody tr:nth-child(even) th[scope=row] {
background: #f6f6f6;
}
@media (prefers-reduced-motion: reduce) {
.freeze-wrap {
scroll-behavior: auto;
}
}(function () {
var wrap = document.querySelector('.freeze-wrap');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = null, t0 = null;
function maxX() { return wrap.scrollWidth - wrap.clientWidth; }
function maxY() { return wrap.scrollHeight - wrap.clientHeight; }
function loop(ts) {
if (!wrap.classList.contains('is-demo')) return;
if (t0 === null) t0 = ts;
var p = ((ts - t0) / 2000) % 1;
var s = p < .5 ? p * 2 : 2 - p * 2;
wrap.scrollLeft = s * maxX();
wrap.scrollTop = s * maxY();
raf = requestAnimationFrame(loop);
}
function stop() {
wrap.classList.remove('is-demo');
if (raf) cancelAnimationFrame(raf);
}
wrap.addEventListener('pointerdown', stop);
wrap.addEventListener('wheel', stop, { passive: true });
wrap.addEventListener('keydown', function (e) {
var step = 24;
if (e.key === 'ArrowRight') { stop(); wrap.scrollLeft += step; }
else if (e.key === 'ArrowLeft') { stop(); wrap.scrollLeft -= step; }
else if (e.key === 'ArrowDown') { stop(); wrap.scrollTop += step; }
else if (e.key === 'ArrowUp') { stop(); wrap.scrollTop -= step; }
else return;
e.preventDefault();
});
if (!reduce) requestAnimationFrame(loop);
})();import { useRef, type CSSProperties } from "react";
import styles from "./StickyHeaderColumnFreeze.module.scss";
export type StickyHeaderColumnFreezeProps = {
columns: string[];
rows: { label: string; values: string[] }[];
/** 헤더·첫 열 배경 색 */
color?: string;
};
type Vars = CSSProperties & { "--color"?: string };
export default function StickyHeaderColumnFreeze({ columns, rows, color = "#2f6df6" }: StickyHeaderColumnFreezeProps) {
const wrap = useRef<HTMLDivElement | null>(null);
const vars: Vars = { "--color": color };
const key = (e: any) => {
const el = wrap.current;
if (!el) return;
const step = 24;
if (e.key === "ArrowRight") { e.preventDefault(); el.scrollLeft += step; }
else if (e.key === "ArrowLeft") { e.preventDefault(); el.scrollLeft -= step; }
else if (e.key === "ArrowDown") { e.preventDefault(); el.scrollTop += step; }
else if (e.key === "ArrowUp") { e.preventDefault(); el.scrollTop -= step; }
};
return (
<div
className={styles.wrap}
style={vars}
ref={wrap}
tabIndex={0}
aria-label="가격 비교표. 화살표 키나 드래그로 스크롤"
onKeyDown={key}
>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.corner}>플랜</th>
{columns.map((c) => <th key={c}>{c}</th>)}
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.label}>
<th scope="row">{r.label}</th>
{r.values.map((v, i) => <td key={i}>{v}</td>)}
</tr>
))}
</tbody>
</table>
</div>
);
}06 · 고정 헤더 + 첫 열 — 측정 누적면적 11.599% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
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스켈레톤 로딩 리스트
무엇이 움직이나
데이터가 오기 전 회색 막대 행 여러 개가 좌우로 빛을 흘리며, 곧 콘텐츠가 채워질 자리를 미리 보여줍니다.

<main class="stage">
<div class="skel-list" aria-busy="true">
<div class="skel-list__row">
<span class="skel-list__avatar" aria-hidden="true"></span>
<span class="skel-list__lines" aria-hidden="true"><span></span><span></span></span>
<span class="skel-list__content"><strong>이서준</strong><span>새 댓글을 남겼습니다</span></span>
</div>
<div class="skel-list__row">
<span class="skel-list__avatar" aria-hidden="true"></span>
<span class="skel-list__lines" aria-hidden="true"><span></span><span></span></span>
<span class="skel-list__content"><strong>박하윤</strong><span>게시글을 공유했습니다</span></span>
</div>
<button class="skel-list__toggle" type="button">콘텐츠 미리보기</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 1.4s; // 한 번 훑는 시간
$easing: linear;
$color: #d8d8de; // 스켈레톤 막대 바탕
$highlight: rgba(255, 255, 255, .95); // 훑고 지나가는 밝은 띠 — 바탕과 대비를 크게 둔다
@include stage($stage-orange);
.skel-list {
width: min(240px, 78vw);
display: flex;
flex-direction: column;
gap: $sp-3;
}
.skel-list__row {
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-3;
border-radius: $r-card;
background: $stage-paper;
}
.skel-list__avatar {
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, $color 25%, $highlight 50%, $color 75%);
background-size: 200% 100%;
animation: skel-shimmer $duration $easing infinite;
}
.skel-list__lines {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: $sp-2;
span {
display: block;
height: 10px;
border-radius: $r-xs;
background: linear-gradient(90deg, $color 25%, $highlight 50%, $color 75%);
background-size: 200% 100%;
animation: skel-shimmer $duration $easing infinite;
&:last-child { width: 60%; }
}
}
.skel-list__content {
display: none;
flex: 1 1 auto;
flex-direction: column;
gap: $sp-1;
font-size: 12px;
strong { font-size: 13px; color: $stage-ink; }
span { color: rgba(23, 20, 26, .6); }
}
.skel-list.is-loaded .skel-list__avatar,
.skel-list.is-loaded .skel-list__lines { display: none; }
.skel-list.is-loaded .skel-list__content { display: flex; }
.skel-list__toggle {
align-self: flex-start;
border: 0;
border-radius: $r-pill;
padding: $sp-1 $sp-3;
background: $stage-ink;
color: $stage-paper;
font-size: 11px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
}
@keyframes skel-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
.skel-list__avatar, .skel-list__lines span { animation-duration: 3.2s; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.skel-list {
width: min(240px, 78vw);
display: flex;
flex-direction: column;
gap: 12px;
}
.skel-list__row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 16px;
background: #ffffff;
}
.skel-list__avatar {
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #d8d8de 25%, rgba(255, 255, 255, 0.95) 50%, #d8d8de 75%);
background-size: 200% 100%;
animation: skel-shimmer 1.4s linear infinite;
}
.skel-list__lines {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.skel-list__lines span {
display: block;
height: 10px;
border-radius: 4px;
background: linear-gradient(90deg, #d8d8de 25%, rgba(255, 255, 255, 0.95) 50%, #d8d8de 75%);
background-size: 200% 100%;
animation: skel-shimmer 1.4s linear infinite;
}
.skel-list__lines span:last-child {
width: 60%;
}
.skel-list__content {
display: none;
flex: 1 1 auto;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.skel-list__content strong {
font-size: 13px;
color: #17141a;
}
.skel-list__content span {
color: rgba(23, 20, 26, 0.6);
}
.skel-list.is-loaded .skel-list__avatar,
.skel-list.is-loaded .skel-list__lines {
display: none;
}
.skel-list.is-loaded .skel-list__content {
display: flex;
}
.skel-list__toggle {
align-self: flex-start;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #17141a;
color: #ffffff;
font-size: 11px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
}
@keyframes skel-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.skel-list__avatar, .skel-list__lines span {
animation-duration: 3.2s;
}
}document.querySelector('.skel-list__toggle').addEventListener('click', function () {
var list = document.querySelector('.skel-list');
var loaded = list.classList.toggle('is-loaded');
list.setAttribute('aria-busy', String(!loaded));
this.textContent = loaded ? '스켈레톤 다시 보기' : '콘텐츠 미리보기';
});import { useState, type CSSProperties } from "react";
import styles from "./SkeletonShimmerRows.module.scss";
export type SkeletonShimmerRowsProps = {
people: { name: string; action: string }[];
loaded?: boolean;
/** 한 번 훑는 시간 (기본 1.4s) */
duration?: number;
};
type Vars = CSSProperties & { "--duration"?: string };
export default function SkeletonShimmerRows({ people, loaded = false, duration = 1400 }: SkeletonShimmerRowsProps) {
const [isLoaded, setIsLoaded] = useState(loaded);
const vars: Vars = { "--duration": `${duration}ms` };
return (
<div className={styles.list} style={vars} aria-busy={!isLoaded}>
{people.map((p) => (
<div className={styles.row} key={p.name}>
{isLoaded ? (
<span className={styles.content}>
<strong>{p.name}</strong>
<span>{p.action}</span>
</span>
) : (
<>
<span className={styles.avatar} aria-hidden="true" />
<span className={styles.lines} aria-hidden="true"><span /><span /></span>
</>
)}
</div>
))}
<button className={styles.toggle} type="button" onClick={() => setIsLoaded((v) => !v)}>
{isLoaded ? "스켈레톤 다시 보기" : "콘텐츠 미리보기"}
</button>
</div>
);
}07 · 스켈레톤 로딩 리스트 — 측정 누적면적 4.918% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
밝은 띠와 바탕의 대비를 크게 둘수록 스크린샷이나 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체크박스 다중 선택 툴바
무엇이 움직이나
행 체크박스를 하나라도 고르면 화면 아래에서 삭제·이동 같은 일괄 작업 툴바가 슬라이드로 올라옵니다.

<main class="stage">
<div class="bulk-list is-demo">
<label class="bulk-list__row bulk-list__row--all">
<input type="checkbox" class="bulk-list__selectall">
<span>전체 선택</span>
</label>
<label class="bulk-list__row">
<input type="checkbox" class="bulk-list__item">
<span>2026년 1분기 보고서.pdf</span>
</label>
<label class="bulk-list__row">
<input type="checkbox" class="bulk-list__item">
<span>디자인 시안 v3.fig</span>
</label>
<div class="bulk-list__toolbar">
<span class="bulk-list__count"><span class="bulk-list__count-num">0</span>개 선택됨</span>
<button type="button">이동</button>
<button type="button">삭제</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 툴바가 올라오는 시간
$easing: $ease-spring;
$color: $subject-blue; // 체크박스·툴바 강조 색
@include stage($stage-paper);
.bulk-list {
position: relative;
overflow: hidden;
width: min(240px, 78vw);
display: flex;
flex-direction: column;
gap: $sp-2;
padding-bottom: $sp-10;
}
.bulk-list__row {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-control;
background: $subject-cream;
font-size: 12px;
color: $stage-ink;
cursor: pointer;
}
.bulk-list__row--all { background: none; font-weight: 700; padding-left: $sp-3; }
.bulk-list input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: $color;
}
.bulk-list input[type="checkbox"]:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.bulk-list__toolbar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-control;
background: $stage-ink;
color: $stage-paper;
transform: translateY(0); // 정지 상태는 보이는 자리 — 반복 재생은 .is-demo 에서만
transition: transform $duration $easing;
button {
margin-left: auto;
border: 0;
border-radius: $r-pill;
padding: $sp-1 $sp-3;
background: $color;
color: $stage-paper;
font-size: 11px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
& + button { margin-left: $sp-1; background: $error; }
}
}
.bulk-list__count { font-size: 11px; }
.bulk-list.has-selection .bulk-list__toolbar { transform: translateY(0); }
.bulk-list.is-demo .bulk-list__toolbar { animation: bulk-toolbar-loop $dur-loop $easing infinite; }
@keyframes bulk-toolbar-loop {
0%, 15% { transform: translateY(120%); }
45%, 65% { transform: translateY(0); }
95%, 100% { transform: translateY(120%); }
}
@media (prefers-reduced-motion: reduce) {
.bulk-list__toolbar { transition: none; }
.bulk-list.is-demo .bulk-list__toolbar { animation: none; transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bulk-list {
position: relative;
overflow: hidden;
width: min(240px, 78vw);
display: flex;
flex-direction: column;
gap: 8px;
padding-bottom: 40px;
}
.bulk-list__row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 12px;
background: #fff7e6;
font-size: 12px;
color: #17141a;
cursor: pointer;
}
.bulk-list__row--all {
background: none;
font-weight: 700;
padding-left: 12px;
}
.bulk-list input[type=checkbox] {
width: 16px;
height: 16px;
accent-color: #2f6df6;
}
.bulk-list input[type=checkbox]:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.bulk-list__toolbar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 12px;
background: #17141a;
color: #ffffff;
transform: translateY(0);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bulk-list__toolbar button {
margin-left: auto;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #2f6df6;
color: #ffffff;
font-size: 11px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
}
.bulk-list__toolbar button + button {
margin-left: 4px;
background: #c2361a;
}
.bulk-list__count {
font-size: 11px;
}
.bulk-list.has-selection .bulk-list__toolbar {
transform: translateY(0);
}
.bulk-list.is-demo .bulk-list__toolbar {
animation: bulk-toolbar-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes bulk-toolbar-loop {
0%, 15% {
transform: translateY(120%);
}
45%, 65% {
transform: translateY(0);
}
95%, 100% {
transform: translateY(120%);
}
}
@media (prefers-reduced-motion: reduce) {
.bulk-list__toolbar {
transition: none;
}
.bulk-list.is-demo .bulk-list__toolbar {
animation: none;
transform: translateY(0);
}
}(function () {
var list = document.querySelector('.bulk-list');
var all = list.querySelector('.bulk-list__selectall');
var items = Array.prototype.slice.call(list.querySelectorAll('.bulk-list__item'));
var count = list.querySelector('.bulk-list__count-num');
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);
list.classList.remove('is-demo');
count.textContent = String(n);
}
all.addEventListener('change', function () {
items.forEach(function (c) { c.checked = all.checked; });
sync();
});
items.forEach(function (c) { c.addEventListener('change', sync); });
})();import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import styles from "./BulkSelectToolbar.module.scss";
export type BulkSelectToolbarProps = {
files: string[];
/** 툴바가 올라오는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 체크박스·툴바 강조 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BulkSelectToolbar({
files,
duration = 300,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: BulkSelectToolbarProps) {
const [checked, setChecked] = useState<boolean[]>(files.map(() => false));
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const count = useMemo(() => checked.filter(Boolean).length, [checked]);
const allChecked = count === files.length;
const someChecked = count > 0 && !allChecked;
const allRef = useRef<HTMLInputElement | null>(null);
// .indeterminate 는 DOM 프로퍼티일 뿐 HTML 속성이 아니라서 JSX 에 그대로 못 준다 — ref 로 직접 써야 한다
// (vanilla 판의 all.indeterminate = n > 0 && n < items.length 와 동일한 지점)
useEffect(() => {
if (allRef.current) allRef.current.indeterminate = someChecked;
}, [someChecked]);
const toggleOne = (i: number) => {
const next = checked.slice();
next[i] = !next[i];
setChecked(next);
};
const toggleAll = () => setChecked(files.map(() => !allChecked));
return (
<div className={`${styles.list} ${count > 0 ? styles.hasSelection : ""}`} style={vars}>
<label className={`${styles.row} ${styles.rowAll}`}>
<input ref={allRef} type="checkbox" checked={allChecked} onChange={toggleAll} />
<span>전체 선택</span>
</label>
{files.map((f, i) => (
<label className={styles.row} key={f}>
<input type="checkbox" checked={checked[i]} onChange={() => toggleOne(i)} />
<span>{f}</span>
</label>
))}
<div className={styles.toolbar}>
<span className={styles.count}>{count}개 선택됨</span>
<button type="button">이동</button>
<button type="button">삭제</button>
</div>
</div>
);
}08 · 체크박스 다중 선택 툴바 — 측정 누적면적 6.635% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
일부만 선택됐을 땐 전체 선택 체크박스가 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숫자 페이지네이션
무엇이 움직이나
페이지 번호 버튼이 나란히 있고, 페이지가 많아지면 가운데가 점 세 개로 줄어들며 현재 페이지만 강조됩니다.

<main class="stage">
<nav class="pg-nav is-demo" aria-label="페이지">
<button class="pg-nav__prev" type="button" aria-label="이전 페이지">‹</button>
<ul class="pg-nav__list"></ul>
<button class="pg-nav__next" type="button" aria-label="다음 페이지">›</button>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-instant; // 버튼 강조가 바뀌는 시간
$easing: $ease-out;
$color: $subject-blue; // 현재 페이지 강조 색
@include stage($stage-ink);
.pg-nav {
display: flex;
align-items: center;
gap: $sp-1;
}
.pg-nav__list {
display: flex;
align-items: center;
gap: $sp-1;
list-style: none;
margin: 0;
padding: 0;
}
.pg-nav__prev, .pg-nav__next, .pg-nav__num {
min-width: 28px;
height: 28px;
padding: 0 $sp-1;
border: 0;
border-radius: $r-pill;
background: $stage-paper;
color: $stage-ink;
font-size: 12px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
transition: background-color $duration $easing, color $duration $easing;
}
.pg-nav__prev:disabled, .pg-nav__next:disabled { opacity: .35; cursor: default; }
.pg-nav__num[aria-current="page"] { background: $color; color: $stage-paper; }
.pg-nav__num:focus-visible, .pg-nav__prev:focus-visible, .pg-nav__next:focus-visible {
outline: 2px solid $stage-yellow; outline-offset: 2px;
}
.pg-nav__ellipsis { color: rgba($stage-paper, .6); font-size: 12px; padding: 0 $sp-1; }
@media (prefers-reduced-motion: reduce) {
.pg-nav__prev, .pg-nav__next, .pg-nav__num { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pg-nav {
display: flex;
align-items: center;
gap: 4px;
}
.pg-nav__list {
display: flex;
align-items: center;
gap: 4px;
list-style: none;
margin: 0;
padding: 0;
}
.pg-nav__prev, .pg-nav__next, .pg-nav__num {
min-width: 28px;
height: 28px;
padding: 0 4px;
border: 0;
border-radius: 999px;
background: #ffffff;
color: #17141a;
font-size: 12px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
transition: background-color 140ms ease-out, color 140ms ease-out;
}
.pg-nav__prev:disabled, .pg-nav__next:disabled {
opacity: 0.35;
cursor: default;
}
.pg-nav__num[aria-current=page] {
background: #2f6df6;
color: #ffffff;
}
.pg-nav__num:focus-visible, .pg-nav__prev:focus-visible, .pg-nav__next:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.pg-nav__ellipsis {
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
padding: 0 4px;
}
@media (prefers-reduced-motion: reduce) {
.pg-nav__prev, .pg-nav__next, .pg-nav__num {
transition: none;
}
}(function () {
var nav = document.querySelector('.pg-nav');
var list = nav.querySelector('.pg-nav__list');
var prev = nav.querySelector('.pg-nav__prev');
var next = nav.querySelector('.pg-nav__next');
var total = 8, current = 3, timer = null;
function pages() {
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;
}
function render() {
list.innerHTML = '';
pages().forEach(function (p) {
var li = document.createElement('li');
if (p === '...') {
li.className = 'pg-nav__ellipsis'; li.textContent = '...'; li.setAttribute('aria-hidden', 'true');
} else {
var b = document.createElement('button');
b.type = 'button'; b.className = 'pg-nav__num'; b.textContent = String(p);
if (p === current) { b.setAttribute('aria-current', 'page'); }
b.addEventListener('click', function () { stop(); current = p; render(); });
li.appendChild(b);
}
list.appendChild(li);
});
prev.disabled = current === 1;
next.disabled = current === total;
}
function stop() { nav.classList.remove('is-demo'); if (timer) { clearInterval(timer); timer = null; } }
prev.addEventListener('click', function () { stop(); if (current > 1) { current--; render(); } });
next.addEventListener('click', function () { stop(); if (current < total) { current++; render(); } });
render();
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
timer = setInterval(function () {
current = current < total ? current + 1 : 1;
render();
}, 260);
}
})();import { useState, type CSSProperties } from "react";
import styles from "./NumberedPagination.module.scss";
export type NumberedPaginationProps = {
total: number;
initial?: number;
/** 현재 페이지 강조 색 */
color?: string;
};
type Vars = CSSProperties & { "--color"?: string };
type Token = number | "...";
function pages(total: number, current: number): Token[] {
const out: Token[] = [];
for (let 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;
}
export default function NumberedPagination({ total, initial = 1, color = "#2f6df6" }: NumberedPaginationProps) {
const [current, setCurrent] = useState(initial);
const vars: Vars = { "--color": color };
return (
<nav className={styles.nav} style={vars} aria-label="페이지">
<button
className={styles.prev}
type="button"
aria-label="이전 페이지"
disabled={current === 1}
onClick={() => setCurrent((c) => Math.max(1, c - 1))}
>‹</button>
<ul className={styles.list}>
{pages(total, current).map((p, i) =>
p === "..." ? (
<li className={styles.ellipsis} key={`e${i}`} aria-hidden="true">...</li>
) : (
<li key={p}>
<button
className={styles.num}
type="button"
aria-current={p === current ? "page" : undefined}
onClick={() => setCurrent(p)}
>{p}</button>
</li>
)
)}
</ul>
<button
className={styles.next}
type="button"
aria-label="다음 페이지"
disabled={current === total}
onClick={() => setCurrent((c) => Math.min(total, c + 1))}
>›</button>
</nav>
);
}09 · 숫자 페이지네이션 — 측정 누적면적 4.884% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
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으로 감싸 고친 버전입니다. 드래그로 항목을 재배치하는 감각을 화면 전체로 키운 구성은 포트폴리오 갤러리 UI에서 다뤘습니다. 이 아홉 개 파일의 압축 비밀번호는 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 — 자주 묻는 질문
스와이프 삭제와 스켈레톤 ui css는 라이브러리 없이 정말 되나요?
네. 여기 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로 옮겨 뒀습니다. 열을 끌어 넓히고 셀을 골라 복사하는 표의 형제는 데이터 테이블 UI 9종입니다.









