06한 방향으로만 밀리는 기본 스와이프 액션
행을 옆으로 밀면 뒤에 숨어 있던 아이콘 버튼 하나가 정해진 폭만큼만 드러나고, 손을 떼면 그 폭에 맞춰 붙거나 되돌아간다 (207의 swipe-actions-delete-reveal·swipe-actions-multi-action-stack — 삭제 전용 색·여러 버튼 겹쌓기를 다루는 판 — 과 다른 시연 — 여긴 버튼 하나·왕복 스냅만 있는 가장 기본형이다).
쓰임메시지함·할 일 목록의 한 가지 빠른 동작
openRowcloseRowcurrentX

<main class="stage">
<div class="swa is-demo" id="swa">
<ul class="swa__list" id="swaList">
<li class="swa__row">
<div class="swa__actions"><button type="button" class="swa__actionBtn" data-del>삭제</button></div>
<div class="swa__content" tabindex="0">받은편지함 안내 1</div>
</li>
<li class="swa__row">
<div class="swa__actions"><button type="button" class="swa__actionBtn" data-del>삭제</button></div>
<div class="swa__content" tabindex="0">받은편지함 안내 2</div>
</li>
<li class="swa__row">
<div class="swa__actions"><button type="button" class="swa__actionBtn" data-del>삭제</button></div>
<div class="swa__content" tabindex="0">받은편지함 안내 3</div>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-quick;
$easing: $ease-spring;
$color: $error;
// 계산 겹: index.html 이 손을 뗄 때의 속도(velocity)를 재서, 임계값을 넘으면
// 멈춘 위치와 무관하게 속도의 방향으로 열림/닫힘을 스냅한다.
@include stage($stage-ink);
.swa {
width: min(100%, 280px);
}
.swa__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.swa__row {
position: relative;
overflow: hidden;
border-radius: $r-sm;
transform-origin: top center;
&.is-deleted {
animation-name: swaRowOut;
animation-duration: $dur-base;
animation-timing-function: $ease-out;
animation-fill-mode: forwards;
}
}
@keyframes swaRowOut {
to { opacity: 0; transform: scaleY(0); }
}
.swa__actions {
position: absolute;
inset: 0;
display: flex;
justify-content: flex-end;
align-items: stretch;
background: rgba($color, .18);
}
.swa__actionBtn {
box-sizing: border-box;
width: 72px;
border: 0;
background: transparent;
color: $error;
font-weight: 700;
font-size: 12px;
cursor: pointer;
&:focus-visible {
outline: 2px solid $error;
outline-offset: -2px;
}
}
.swa__content {
--swipe-x: 0px;
box-sizing: border-box;
position: relative;
padding: $sp-3;
border-radius: $r-sm;
background: rgba(255, 255, 255, .08);
color: $stage-paper;
font-weight: 600;
font-size: 12px;
transform: translateX(var(--swipe-x));
transition: transform $duration $easing;
touch-action: pan-y;
&.is-dragging {
transition: none;
}
&:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
}
@media (max-width: 340px) {
.swa__content { padding: $sp-2; font-size: 11px; }
.swa__actionBtn { width: 60px; font-size: 11px; }
}
// reduce: 실사용 경로 적용 — 스냅 트랜지션과 자동 루프만 멈춘다. 드래그·속도 계산은 그대로 동작
@media (prefers-reduced-motion: reduce) {
.swa__content { transition: none; }
.swa__row.is-deleted { animation: none; opacity: 0; }
}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);
}
.swa {
width: min(100%, 280px);
}
.swa__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.swa__row {
position: relative;
overflow: hidden;
border-radius: 8px;
transform-origin: top center;
}
.swa__row.is-deleted {
animation-name: swaRowOut;
animation-duration: 300ms;
animation-timing-function: ease-out;
animation-fill-mode: forwards;
}
@keyframes swaRowOut {
to {
opacity: 0;
transform: scaleY(0);
}
}
.swa__actions {
position: absolute;
inset: 0;
display: flex;
justify-content: flex-end;
align-items: stretch;
background: rgba(194, 54, 26, 0.18);
}
.swa__actionBtn {
box-sizing: border-box;
width: 72px;
border: 0;
background: transparent;
color: #c2361a;
font-weight: 700;
font-size: 12px;
cursor: pointer;
}
.swa__actionBtn:focus-visible {
outline: 2px solid #c2361a;
outline-offset: -2px;
}
.swa__content {
--swipe-x: 0px;
box-sizing: border-box;
position: relative;
padding: 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-weight: 600;
font-size: 12px;
transform: translateX(var(--swipe-x));
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
touch-action: pan-y;
}
.swa__content.is-dragging {
transition: none;
}
.swa__content:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.swa__content {
padding: 8px;
font-size: 11px;
}
.swa__actionBtn {
width: 60px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.swa__content {
transition: none;
}
.swa__row.is-deleted {
animation: none;
opacity: 0;
}
}(function () {
var ACTION_WIDTH = 72; // 열렸을 때 드러나는 버튼 폭
var VELOCITY_OPEN_THRESHOLD = 0.5; // px/ms — 이 속도를 넘기면 위치와 무관하게 방향이 결정된다
var AUTO_CYCLE_MS = 2000;
function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); }
var root = document.getElementById('swa');
var list = document.getElementById('swaList');
var rows = Array.prototype.slice.call(list.querySelectorAll('.swa__row'));
var rowApis = [];
rows.forEach(function (row, idx) {
var content = row.querySelector('.swa__content');
var delBtn = row.querySelector('[data-del]');
var dragging = false;
var startX = 0;
var baseX = 0;
var lastX = 0;
var lastT = 0;
var velocity = 0;
var isOpen = false;
function setX(x, animate) {
content.style.setProperty('--swipe-x', x.toFixed(1) + 'px');
content.classList.toggle('is-dragging', !animate);
}
function openRow(animate) {
setX(-ACTION_WIDTH, animate !== false);
isOpen = true;
delBtn.tabIndex = 0;
}
function closeRow(animate) {
setX(0, animate !== false);
isOpen = false;
delBtn.tabIndex = -1;
}
closeRow(false);
function currentX() {
var v = content.style.getPropertyValue('--swipe-x');
return v ? parseFloat(v) : 0;
}
content.addEventListener('pointerdown', function (e) {
human();
rowApis.forEach(function (api, otherIdx) { if (otherIdx !== idx) { api.close(true); } });
dragging = true;
startX = e.clientX;
baseX = currentX();
lastX = e.clientX;
lastT = performance.now();
velocity = 0;
content.setPointerCapture(e.pointerId);
});
content.addEventListener('pointermove', function (e) {
if (!dragging) { return; }
var now = performance.now();
var dt = Math.max(now - lastT, 1);
velocity = (e.clientX - lastX) / dt; // px/ms, 음수 = 왼쪽으로 밀리는 중
lastX = e.clientX;
lastT = now;
var raw = baseX + (e.clientX - startX);
setX(clamp(raw, -ACTION_WIDTH, 0), false);
});
content.addEventListener('pointerup', function () {
if (!dragging) { return; }
dragging = false;
// 계산 겹: 손을 뗄 때의 속도가 임계값을 넘으면, 멈춘 위치와 무관하게
// 속도의 방향이 열림/닫힘을 결정한다. 느리면 위치 절반 기준으로 결정한다.
var openDecision;
if (velocity < -VELOCITY_OPEN_THRESHOLD) {
openDecision = true;
} else if (velocity > VELOCITY_OPEN_THRESHOLD) {
openDecision = false;
} else {
openDecision = currentX() < -ACTION_WIDTH / 2;
}
if (openDecision) { openRow(); } else { closeRow(); }
});
delBtn.tabIndex = -1;
delBtn.addEventListener('click', function () {
human();
row.classList.add('is-deleted');
});
rowApis.push({ open: openRow, close: closeRow });
});
function human() {
if (root.classList.contains('is-demo')) {
root.classList.remove('is-demo');
if (timer) { window.clearInterval(timer); timer = null; }
}
}
var timer = null;
var start = 0;
function autoApply() {
var elapsed = performance.now() - start;
var phase = (elapsed % AUTO_CYCLE_MS) / AUTO_CYCLE_MS;
var x;
if (phase < 0.35) {
x = -ACTION_WIDTH * (phase / 0.35);
} else if (phase < 0.65) {
x = -ACTION_WIDTH;
} else if (phase < 0.85) {
x = -ACTION_WIDTH * (1 - (phase - 0.65) / 0.2);
} else {
x = 0;
}
var content = rows[0].querySelector('.swa__content');
content.classList.add('is-dragging');
content.style.setProperty('--swipe-x', x.toFixed(1) + 'px');
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
start = performance.now();
timer = window.setInterval(autoApply, 40);
} else {
// reduce: 실사용 경로 적용 — 자동 스와이프 루프를 멈추고 닫힌 상태를 고정으로 보여준다
}
})();// SwipeActions — vanilla 판(run/381/demo/06-swipe-actions/index.html)의 React 포트.
// 손을 뗄 때의 속도(velocity = 이동거리/경과시간)가 임계값을 넘으면 멈춘 위치와 무관하게
// 속도의 방향이 열림/닫힘을 결정한다 — 짧고 빠른 스와이프도 인식하는 실제 동작이다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./SwipeActions.module.scss";
export type SwipeActionsProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const LABELS = ["받은편지함 안내 1", "받은편지함 안내 2", "받은편지함 안내 3"];
export default function SwipeActions({
duration = "220ms", // vanilla $duration: $dur-quick
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#c2361a", // vanilla $color: $error
}: SwipeActionsProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const list = root.querySelector<HTMLUListElement>('[data-role="list"]');
if (!list) return;
const rows = Array.prototype.slice.call(list.querySelectorAll("[data-row]")) as HTMLLIElement[];
const ACTION_WIDTH = 72; // 열렸을 때 드러나는 버튼 폭
const VELOCITY_OPEN_THRESHOLD = 0.5; // px/ms — 이 속도를 넘기면 위치와 무관하게 방향이 결정된다
const AUTO_CYCLE_MS = 2000;
function clamp(v: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, v));
}
function human(): void {
if (root!.classList.contains("is-demo")) {
root!.classList.remove("is-demo");
if (timer) {
window.clearInterval(timer);
timer = null;
}
}
}
type RowApi = { open: (animate?: boolean) => void; close: (animate?: boolean) => void };
const rowApis: RowApi[] = [];
const cleanups: (() => void)[] = [];
rows.forEach((row, idx) => {
const content = row.querySelector<HTMLDivElement>('[data-role="content"]')!;
const delBtn = row.querySelector<HTMLButtonElement>("[data-del]")!;
let dragging = false;
let startX = 0;
let baseX = 0;
let lastX = 0;
let lastT = 0;
let velocity = 0;
function setX(x: number, animate: boolean): void {
content.style.setProperty("--swipe-x", x.toFixed(1) + "px");
content.classList.toggle("is-dragging", !animate);
}
function currentX(): number {
const v = content.style.getPropertyValue("--swipe-x");
return v ? parseFloat(v) : 0;
}
function openRow(animate?: boolean): void {
setX(-ACTION_WIDTH, animate !== false);
delBtn.tabIndex = 0;
}
function closeRow(animate?: boolean): void {
setX(0, animate !== false);
delBtn.tabIndex = -1;
}
closeRow(false);
delBtn.tabIndex = -1;
const rowApi: RowApi = { open: openRow, close: closeRow };
rowApis.push(rowApi);
function onPointerDown(e: PointerEvent): void {
human();
rowApis.forEach((api, otherIdx) => {
if (otherIdx !== idx) api.close(true);
});
dragging = true;
startX = e.clientX;
baseX = currentX();
lastX = e.clientX;
lastT = performance.now();
velocity = 0;
content.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent): void {
if (!dragging) return;
const now = performance.now();
const dt = Math.max(now - lastT, 1);
velocity = (e.clientX - lastX) / dt; // px/ms, 음수 = 왼쪽으로 밀리는 중
lastX = e.clientX;
lastT = now;
const raw = baseX + (e.clientX - startX);
setX(clamp(raw, -ACTION_WIDTH, 0), false);
}
function onPointerUp(): void {
if (!dragging) return;
dragging = false;
// 계산 겹: 손을 뗄 때의 속도가 임계값을 넘으면, 멈춘 위치와 무관하게
// 속도의 방향이 열림/닫힘을 결정한다. 느리면 위치 절반 기준으로 결정한다.
let openDecision: boolean;
if (velocity < -VELOCITY_OPEN_THRESHOLD) {
openDecision = true;
} else if (velocity > VELOCITY_OPEN_THRESHOLD) {
openDecision = false;
} else {
openDecision = currentX() < -ACTION_WIDTH / 2;
}
if (openDecision) openRow();
else closeRow();
}
content.addEventListener("pointerdown", onPointerDown);
content.addEventListener("pointermove", onPointerMove);
content.addEventListener("pointerup", onPointerUp);
function onDelClick(): void {
human();
row.classList.add("is-deleted");
}
delBtn.addEventListener("click", onDelClick);
cleanups.push(() => {
content.removeEventListener("pointerdown", onPointerDown);
content.removeEventListener("pointermove", onPointerMove);
content.removeEventListener("pointerup", onPointerUp);
delBtn.removeEventListener("click", onDelClick);
});
});
let timer: number | null = null;
let start = 0;
// 계산 겹: 2초 주기를 네 구간(진입 35%·유지 30%·복귀 20%·대기 15%)의 조각 선형 함수로 나눠
// 첫 행이 실제 드래그처럼 열렸다 닫히는 것을 자동 재생으로 보여준다.
function autoApply(): void {
const elapsed = performance.now() - start;
const phase = (elapsed % AUTO_CYCLE_MS) / AUTO_CYCLE_MS;
let x: number;
if (phase < 0.35) {
x = -ACTION_WIDTH * (phase / 0.35);
} else if (phase < 0.65) {
x = -ACTION_WIDTH;
} else if (phase < 0.85) {
x = -ACTION_WIDTH * (1 - (phase - 0.65) / 0.2);
} else {
x = 0;
}
const content = rows[0]?.querySelector<HTMLDivElement>('[data-role="content"]');
if (!content) return;
content.classList.add("is-dragging");
content.style.setProperty("--swipe-x", x.toFixed(1) + "px");
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
start = performance.now();
timer = window.setInterval(autoApply, 40);
}
// reduce: 실사용 경로 적용 — 자동 스와이프 루프를 멈추고 닫힌 상태를 고정으로 보여준다
return () => {
cleanups.forEach((fn) => fn());
if (timer) {
window.clearInterval(timer);
}
};
}, []);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<main className={styles.stage}>
<div className={`${styles.swa} is-demo`} ref={rootRef} style={vars}>
<ul className={styles.swa__list} data-role="list">
{LABELS.map((label, i) => (
<li className={styles.swa__row} data-row={i} key={i}>
<div className={styles.swa__actions}>
<button type="button" data-del className={styles.swa__actionBtn}>
삭제
</button>
</div>
<div className={styles.swa__content} data-role="content" tabIndex={0}>
{label}
</div>
</li>
))}
</ul>
</div>
</main>
);
}06 한 방향으로만 밀리는 기본 스와이프 액션 — 측정 누적면적 2.771% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 무한 스크롤 구현 9종 — 리스트·테이블 UI 기본 패턴
이 데모가 속한 글
무한 스크롤 구현 9종 — 리스트·테이블 UI 기본 패턴무한 스크롤 구현 아홉 가지 중 하나는 새로 붙는 행이 로딩 스피너 없이 순서대로 미끄러져 들어오고, 하나는 정렬 버튼을 누르면 행들이 실제로 자리를 맞바꾸며 이동하고, 하나는 가로로 스크롤할 때 얼어붙은 첫 칸에만 이 글의 데모 전부 보기


