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.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Swipe-to-delete reveal
- 02 Swipe multi-action stack
- 03 Pull-to-refresh spinner
- 04 Drag-handle reorder
- 05 Click-to-sort header
- 06 Frozen header + first column
- 07 Skeleton shimmer rows
- 08 Bulk-select toolbar
- 09 Numbered pagination
The nine aren't ranked by popularity — they follow the order a list actually goes through. First come one-row gestures (01-02 swipe, 03 pull to refresh), then whole-list operations (04-05 reordering and the sortable table, 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). Tapping a row to expand it belongs to 9 Accordion List UI Patterns, and comparing two photos by a draggable handle belongs to 9 Before After Slider UIs.
01Swipe-to-delete reveal
What moves
Dragging a row left reveals a red delete button behind it, and dragging past halfway auto-dismisses the whole row.

<main class="stage">
<ul class="swipe-list" aria-label="Inbox">
<li class="swipe-row is-demo">
<button class="swipe-row__delete" type="button">Delete</button>
<div class="swipe-row__content" tabindex="0">
<span class="swipe-row__title">Weekly meeting reminder</span>
<span class="swipe-row__desc">Swipe left to try it</span>
</div>
</li>
<li class="swipe-row">
<button class="swipe-row__delete" type="button">Delete</button>
<div class="swipe-row__content" tabindex="0">
<span class="swipe-row__title">Design feedback</span>
<span class="swipe-row__desc">Yesterday 6:20 PM</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 · Swipe-to-delete reveal — Measured area 6.517% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
The button is a real, always-present <button>, so tabbing to the row and pressing Delete works just as well as a finger.
// pointermove sets --dx directly, then two thresholds decide what happens on release
content.addEventListener('pointermove', function (e) {
dx = Math.min(0, Math.max(-170, e.clientX - startX));
content.style.transform = 'translateX(' + dx + 'px)';
});
function end() {
if (dx < -140) { removeRow(); } // past the delete threshold
else content.style.transform = dx < -44 ? 'translateX(-88px)' : '';
}
02Swipe multi-action stack
What moves
The further you drag, the more buttons — star, archive, and delete — pop in from the side and stack up in order.

<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">Archive</button>
<button class="ma-row__action ma-row__action--del" type="button">Delete</button>
</div>
<div class="ma-row__content" tabindex="0">
<span class="ma-row__title">3 new comments</span>
<span class="ma-row__desc">Someone commented on your post</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 · Swipe multi-action stack — Measured area 12.767% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
All three are real buttons, so tabbing to the row reveals them for keyboard users too.
.action {
opacity: 0;
transform: translateY(8px);
transition: opacity .22s, transform .22s;
}
// keyboard focus landing on the row (not just a hover) reveals the same three buttons
.row:focus-within .action { opacity: 1; transform: translateY(0); }
.action--fav { animation-delay: 0ms; }
.action--archive { animation-delay: 80ms; }
.action--del { animation-delay: 160ms; }
03Pull-to-refresh spinner
What moves
Dragging down from the top of the list stretches with rubber-band resistance, then releases into a spinning refresh.

<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">Your feed</span>
<button class="ptr__refresh" type="button">Refresh</button>
</div>
<ul class="ptr__list">
<li>3 picks for today</li>
<li>2 friend updates</li>
</ul>
<p class="ptr__status" aria-live="polite">Just refreshed</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 · Pull-to-refresh spinner — Measured area 14.518% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
A visible refresh button triggers the exact same animation for anyone who can't perform the drag.
function end() {
if (dy > 44) refresh(); // pulled past the halfway mark
else body.style.transform = ''; // otherwise it springs back
}
function refresh() {
body.style.transform = 'translateY(56px)';
setTimeout(function () { body.style.transform = ''; }, 900);
}
04Drag-handle reorder
What moves
Grabbing the six-dot handle on the left and dragging up or down nudges the rows it passes aside and reorders the list.

<main class="stage">
<ul class="drag-list is-demo" aria-label="Processing order">
<li class="drag-row">
<button class="drag-row__handle" type="button" aria-label="Drag, or use arrow keys to reorder">⠿</button>
<span class="drag-row__label">Payment confirmed</span>
</li>
<li class="drag-row">
<button class="drag-row__handle" type="button" aria-label="Drag, or use arrow keys to reorder">⠿</button>
<span class="drag-row__label">Preparing shipment</span>
</li>
<li class="drag-row">
<button class="drag-row__handle" type="button" aria-label="Drag, or use arrow keys to reorder">⠿</button>
<span class="drag-row__label">Review request</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 · Drag-handle reorder — Measured area 18.702% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
Without a pointer at all, focusing the handle and pressing the arrow keys reorders it the same way.
handle.addEventListener('keydown', function (e) {
var sib = e.key === 'ArrowDown' ? row.nextElementSibling
: e.key === 'ArrowUp' ? row.previousElementSibling : null;
if (!sib) return;
e.preventDefault();
e.key === 'ArrowDown' ? list.insertBefore(sib, row) : list.insertBefore(row, sib);
handle.focus({ preventScroll: true }); // keeps the parent page from jumping under an iframe
});
05Click-to-sort header
What moves
Clicking a column header flips its arrow and re-sorts the rows by that column, toggling ascending and descending.

<main class="stage">
<table class="sort-table is-demo">
<caption class="sort-table__caption">Participant scores</caption>
<thead>
<tr>
<th><button class="sort-table__th" type="button" data-key="name" aria-sort="none">Name<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">Score<span class="sort-table__arrow" aria-hidden="true">▾</span></button></th>
</tr>
</thead>
<tbody>
<tr><td>Do-yoon</td><td data-score="92">92</td></tr>
<tr><td>Seo-yeon</td><td data-score="88">88</td></tr>
<tr><td>Min-jun</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 · Click-to-sort header — Measured area 3.665% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
The current state is exposed through aria-sort, so a screen reader announces it the same way a sighted user sees the arrow flip.
btn.addEventListener('click', function () {
var dir = btn.getAttribute('aria-sort') === 'ascending' ? 'descending' : 'ascending';
ths.forEach(function (b) { b.setAttribute('aria-sort', 'none'); });
btn.setAttribute('aria-sort', dir);
rows.sort(function (a, b) {
var cmp = a[key] > b[key] ? 1 : a[key] < b[key] ? -1 : 0;
return dir === 'ascending' ? cmp : -cmp;
}).forEach(function (r) { tbody.appendChild(r); });
});
06Frozen header + first column
What moves
Scroll the table in any direction and the top header row plus the leftmost column stay pinned in place.

<main class="stage">
<div class="freeze-wrap is-demo" tabindex="0" aria-label="Price comparison table. Scroll with arrow keys or drag">
<table class="freeze-table">
<thead>
<tr>
<th class="freeze-table__corner">Plan</th>
<th>Jan</th><th>Feb</th><th>Mar</th><th>Apr</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">Basic</th><td>9,900</td><td>9,900</td><td>9,900</td><td>9,900</td></tr>
<tr><th scope="row">Pro</th><td>19,900</td><td>19,900</td><td>19,900</td><td>19,900</td></tr>
<tr><th scope="row">Team</th><td>39,900</td><td>39,900</td><td>39,900</td><td>39,900</td></tr>
<tr><th scope="row">Enterprise</th><td>Contact us</td><td>Contact us</td><td>Contact us</td><td>Contact us</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 · Frozen header + first column — Measured area 11.599% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
position: sticky gets both a top and a left, and only the one cell where both apply needs a higher z-index.
// header row — stays put while the table scrolls vertically
thead th {
position: sticky;
top: 0;
z-index: 2;
}
// first column — stays put while the table scrolls horizontally
tbody th[scope="row"] {
position: sticky;
left: 0;
z-index: 1;
}
// the one cell where both apply needs to sit above both layers
.corner { position: sticky; top: 0; left: 0; z-index: 3; }
07Skeleton shimmer rows
What moves
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.

<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>Lee Seo-jun</strong><span>left a new comment</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>Park Ha-yoon</strong><span>shared your post</span></span>
</div>
<button class="skel-list__toggle" type="button">Preview content</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 ? 'Show skeleton again' : 'Preview content';
});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 · Skeleton shimmer rows — Measured area 4.918% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
A toggle button swaps the skeleton for loaded content on demand, so you can compare both states side by side.
.bar {
background: linear-gradient(90deg, #d8d8de 25%, rgba(255, 255, 255, .95) 50%, #d8d8de 75%);
background-size: 200% 100%;
animation: shimmer 1.4s linear infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
08Bulk-select toolbar
What moves
Checking even one row checkbox slides up a bottom toolbar with bulk actions like delete and move.

<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>Select all</span>
</label>
<label class="bulk-list__row">
<input type="checkbox" class="bulk-list__item">
<span>2026-Q1-report.pdf</span>
</label>
<label class="bulk-list__row">
<input type="checkbox" class="bulk-list__item">
<span>design-draft-v3.fig</span>
</label>
<div class="bulk-list__toolbar">
<span class="bulk-list__count"><span class="bulk-list__count-num">0</span> selected</span>
<button type="button">Move</button>
<button type="button">Delete</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 · Bulk-select toolbar — Measured area 6.635% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
When only some rows are checked, the select-all box switches to an indeterminate state to show it isn't "all or nothing."
function sync() {
var n = items.filter(function (c) { return c.checked; }).length;
all.checked = n === items.length;
all.indeterminate = n > 0 && n < items.length; // neither none nor all are checked
list.classList.toggle('has-selection', n > 0); // this is what slides the toolbar up
}
09Numbered pagination
What moves
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.

<main class="stage">
<nav class="pg-nav is-demo" aria-label="Page">
<button class="pg-nav__prev" type="button" aria-label="Previous page">‹</button>
<ul class="pg-nav__list"></ul>
<button class="pg-nav__next" type="button" aria-label="Next page">›</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 · Numbered pagination — Measured area 4.884% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
This demo runs on eight pages, so only the first, the last, and whatever sits next to the current one keeps its own number.
function pages(total, current) {
var out = [];
for (var p = 1; p <= total; p++) {
// keep a number for the first page, the last page, or a neighbor of the current one
if (p === 1 || p === total || Math.abs(p - current) <= 1) {
out.push(p);
} else if (out[out.length - 1] !== '...') {
out.push('...'); // a run of skipped numbers collapses into one dot
}
}
return out;
}
Where list swipe UI breaks
pull to refresh — where does it break?
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. Scaling that drag-to-reorder feel up to a whole screen is what Portfolio Gallery HTML CSS covers. 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
What we checked
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.
Where to go next
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 or css skeleton loading really work without a JavaScript 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. Its table sibling — columns you drag wider, cells you select and copy — is 9 Data Table UI Patterns.









