02スワイプ複数アクション
さらにスワイプするほど、お気に入り・アーカイブ・削除ボタンが順番に横から現れて並ぶ。
使いどころメールのアーカイブ・削除、通知リストの複数操作
stagger revealtransform 순차 지연touch-action: pan-y

<main class="stage">
<ul class="ma-list">
<li class="ma-row is-demo">
<div class="ma-row__actions">
<button class="ma-row__action ma-row__action--fav" type="button" aria-pressed="false">★</button>
<button class="ma-row__action ma-row__action--archive" type="button">アーカイブ</button>
<button class="ma-row__action ma-row__action--del" type="button">削除</button>
</div>
<div class="ma-row__content" tabindex="0">
<span class="ma-row__title">新しいコメント3件</span>
<span class="ma-row__desc">投稿に新しいコメントが付きました</span>
</div>
</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 콘텐츠가 밀리는 시간
$easing: $ease-pop;
$color: $subject-blue; // 보관 버튼 색
@include stage($stage-yellow);
.ma-list { list-style: none; margin: 0; padding: 0; width: min(280px, 82vw); }
.ma-row {
position: relative;
overflow: hidden;
border-radius: $r-card;
transition: height $duration $easing, opacity $duration $easing;
}
.ma-row__actions {
position: absolute;
inset: 0 0 0 auto;
display: flex;
width: 198px;
}
.ma-row__action {
flex: 1 0 66px;
border: 0;
color: $stage-paper;
font-weight: 700;
font-size: 13px;
font-family: inherit;
cursor: pointer;
opacity: 0;
transform: translateY(8px);
transition: opacity $duration $easing, transform $duration $easing, background-color $duration $easing;
}
.ma-row__action--fav { background: $stage-ink; }
.ma-row__action--fav.is-on { background: $stage-yellow; color: $stage-ink; }
.ma-row__action--archive { background: $color; }
.ma-row__action--del { background: $error; }
.ma-row__content {
position: relative;
display: flex;
flex-direction: column;
gap: $sp-1;
padding: $sp-4;
border-radius: $r-card;
background: $stage-paper;
transform: translateX(0);
transition: transform $duration $easing;
cursor: grab;
touch-action: pan-y;
}
.ma-row__content:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.ma-row:focus-within .ma-row__action { opacity: 1; transform: translateY(0); }
.ma-row__title { display: block; font-weight: 700; font-size: 14px; color: $stage-ink; }
.ma-row__desc { display: block; margin-top: $sp-1; font-size: 12px; color: rgba(23, 20, 26, .6); }
.ma-row.is-demo .ma-row__content { animation: ma-content-loop $dur-loop $easing infinite; }
.ma-row.is-demo .ma-row__action { animation: ma-action-loop $dur-loop $easing infinite; }
.ma-row.is-demo .ma-row__action--fav { animation-delay: 0ms; }
.ma-row.is-demo .ma-row__action--archive { animation-delay: 80ms; }
.ma-row.is-demo .ma-row__action--del { animation-delay: 160ms; }
@keyframes ma-content-loop {
0%, 15% { transform: translateX(0); }
50%, 70% { transform: translateX(-198px); }
95%, 100% { transform: translateX(0); }
}
@keyframes ma-action-loop {
0%, 15% { opacity: 0; transform: translateY(8px); }
50%, 70% { opacity: 1; transform: translateY(0); }
95%, 100% { opacity: 0; transform: translateY(8px); }
}
@media (prefers-reduced-motion: reduce) {
.ma-row__content, .ma-row__action, .ma-row { transition: none; }
.ma-row.is-demo .ma-row__content { animation: none; transform: translateX(-198px); }
.ma-row.is-demo .ma-row__action { animation: none; opacity: 1; transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ma-list {
list-style: none;
margin: 0;
padding: 0;
width: min(280px, 82vw);
}
.ma-row {
position: relative;
overflow: hidden;
border-radius: 16px;
transition: height 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ma-row__actions {
position: absolute;
inset: 0 0 0 auto;
display: flex;
width: 198px;
}
.ma-row__action {
flex: 1 0 66px;
border: 0;
color: #ffffff;
font-weight: 700;
font-size: 13px;
font-family: inherit;
cursor: pointer;
opacity: 0;
transform: translateY(8px);
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), background-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ma-row__action--fav {
background: #17141a;
}
.ma-row__action--fav.is-on {
background: #ffd23f;
color: #17141a;
}
.ma-row__action--archive {
background: #2f6df6;
}
.ma-row__action--del {
background: #c2361a;
}
.ma-row__content {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px;
border-radius: 16px;
background: #ffffff;
transform: translateX(0);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
cursor: grab;
touch-action: pan-y;
}
.ma-row__content:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ma-row:focus-within .ma-row__action {
opacity: 1;
transform: translateY(0);
}
.ma-row__title {
display: block;
font-weight: 700;
font-size: 14px;
color: #17141a;
}
.ma-row__desc {
display: block;
margin-top: 4px;
font-size: 12px;
color: rgba(23, 20, 26, 0.6);
}
.ma-row.is-demo .ma-row__content {
animation: ma-content-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.ma-row.is-demo .ma-row__action {
animation: ma-action-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.ma-row.is-demo .ma-row__action--fav {
animation-delay: 0ms;
}
.ma-row.is-demo .ma-row__action--archive {
animation-delay: 80ms;
}
.ma-row.is-demo .ma-row__action--del {
animation-delay: 160ms;
}
@keyframes ma-content-loop {
0%, 15% {
transform: translateX(0);
}
50%, 70% {
transform: translateX(-198px);
}
95%, 100% {
transform: translateX(0);
}
}
@keyframes ma-action-loop {
0%, 15% {
opacity: 0;
transform: translateY(8px);
}
50%, 70% {
opacity: 1;
transform: translateY(0);
}
95%, 100% {
opacity: 0;
transform: translateY(8px);
}
}
@media (prefers-reduced-motion: reduce) {
.ma-row__content, .ma-row__action, .ma-row {
transition: none;
}
.ma-row.is-demo .ma-row__content {
animation: none;
transform: translateX(-198px);
}
.ma-row.is-demo .ma-row__action {
animation: none;
opacity: 1;
transform: translateY(0);
}
}(function () {
var OPEN = 198;
document.querySelectorAll('.ma-row').forEach(function (row) {
var content = row.querySelector('.ma-row__content');
var fav = row.querySelector('.ma-row__action--fav');
var startX = 0, dx = 0, dragging = false;
function collapse() {
row.style.height = row.offsetHeight + 'px';
requestAnimationFrame(function () { row.style.height = '0px'; row.style.opacity = '0'; });
row.addEventListener('transitionend', function h(e) {
if (e.propertyName === 'height') { row.removeEventListener('transitionend', h); row.remove(); }
});
}
content.addEventListener('pointerdown', function (e) {
row.classList.remove('is-demo');
dragging = true; startX = e.clientX; content.setPointerCapture(e.pointerId);
});
content.addEventListener('pointermove', function (e) {
if (!dragging) return;
dx = Math.min(0, Math.max(-OPEN, e.clientX - startX));
content.style.transform = 'translateX(' + dx + 'px)';
});
function end() {
if (!dragging) return;
dragging = false;
content.style.transform = dx < -OPEN / 2 ? 'translateX(-' + OPEN + 'px)' : '';
dx = 0;
}
content.addEventListener('pointerup', end);
content.addEventListener('pointercancel', end);
fav.addEventListener('click', function () {
var on = fav.getAttribute('aria-pressed') === 'true';
fav.setAttribute('aria-pressed', String(!on));
fav.classList.toggle('is-on', !on);
});
row.querySelector('.ma-row__action--archive').addEventListener('click', collapse);
row.querySelector('.ma-row__action--del').addEventListener('click', collapse);
});
})();import { useRef, useState, type CSSProperties, type PointerEvent } from "react";
import styles from "./SwipeMultiActionStack.module.scss";
export type SwipeMultiActionStackProps = {
title: string;
desc: string;
/** 콘텐츠가 밀리는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 보관 버튼 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const OPEN = 198;
export default function SwipeMultiActionStack({
title,
desc,
duration = 220,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#2f6df6",
}: SwipeMultiActionStackProps) {
const [dx, setDx] = useState(0);
const [fav, setFav] = useState(false);
const [gone, setGone] = useState(false);
const dragging = useRef(false);
const startX = useRef(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
if (gone) return null;
const down = (e: PointerEvent<HTMLDivElement>) => {
dragging.current = true;
startX.current = e.clientX;
e.currentTarget.setPointerCapture(e.pointerId);
};
const move = (e: PointerEvent<HTMLDivElement>) => {
if (!dragging.current) return;
setDx(Math.min(0, Math.max(-OPEN, e.clientX - (startX.current ?? 0))));
};
const up = () => {
if (!dragging.current) return;
dragging.current = false;
setDx(dx < -OPEN / 2 ? -OPEN : 0);
};
return (
<li className={styles.row} style={vars}>
<div className={styles.actions} style={{ opacity: dx < 0 ? 1 : undefined }}>
<button className={styles.fav} type="button" aria-pressed={fav} onClick={() => setFav((v) => !v)}>★</button>
<button className={styles.archive} type="button" onClick={() => setGone(true)}>보관</button>
<button className={styles.delBtn} type="button" onClick={() => setGone(true)}>삭제</button>
</div>
<div
className={styles.content}
tabIndex={0}
style={{ transform: `translateX(${dx}px)` }}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
>
<span className={styles.title}>{title}</span>
<span className={styles.desc}>{desc}</span>
</div>
</li>
);
}02 スワイプ複数アクション — 計測 累積面積 12.767% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · スワイプ削除リストUI 9選 — ページネーションまで
このデモが属する記事
スワイプ削除リストUI 9選 — ページネーションまでスワイプ削除とは、リストの行を横に押すと裏に隠れていた削除ボタンが現れる動きのことで、アプリで見たあの操作をウェブでもそのまま使いたい人は多いです。この記事では、そのスワイプ削除を含むリストのインタラクションを9個、実際に動くコードでまとめ この記事のデモをすべて見る


