04이동 거리 값을 넘기면 사라지는 스와이프 항목
손가락이 옆으로 끄는 이동 거리 값이 기준을 넘으면 그 항목의 투명도 값과 높이 값을 함께 0으로 줄여 목록에서 지운다.
쓰임모바일 알림 목록의 개별 삭제
SWIPE_DISMISS_RATIOSWIPE_VELOCITY_PX_MSendDrag

<main class="stage">
<div class="sw is-demo" id="sw">
<ul class="sw__list" id="swList" role="list"></ul>
<p class="sw__sr" id="swSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 이 pointerdown 좌표·시각을 기록해 pointermove 로 실제 이동 거리(dx)를
// 얻고 pointerup 시점에 dx ÷ 경과시간으로 속도(px/ms)를 계산한다 — 거리 비율(threshold)이나
// 속도 중 하나만 넘어도 지워지므로, 톡 치듯 빠르게 밀면 짧게 밀어도 지워진다.
.stage { @include stage($stage-paper); }
.sw { width: min(100%, 300px); position: relative; }
.sw__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $sp-2; }
.sw__item {
position: relative;
overflow: hidden;
border-radius: $r-sm;
box-sizing: border-box;
transition: height $dur-base $ease-spring, margin-bottom $dur-base $ease-spring;
}
.sw__reveal {
position: absolute;
inset: 0;
background: $error;
color: $subject-cream;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: $sp-3;
opacity: 0;
box-sizing: border-box;
}
.sw__row {
position: relative;
background: $subject-cream;
border-radius: $r-sm;
padding: $sp-3;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
color: $subject-ink;
font-size: 12px;
touch-action: pan-y;
cursor: grab;
user-select: none;
}
.sw__del {
border: none;
background: transparent;
color: rgba($subject-ink, .45);
width: 22px;
height: 22px;
display: grid;
place-items: center;
cursor: pointer;
flex: 0 0 auto;
}
.sw__xic { width: 16px; height: 16px; }
.sw__del .sw__xic { width: 14px; height: 14px; }
.sw__sr {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip: rect(0 0 0 0); margin: -1px; padding: 0; border: 0;
}
@media (max-width: 340px) {
.sw { width: 100%; }
.sw__list { max-height: 130px; overflow-y: auto; }
}
@media (prefers-reduced-motion: reduce) {
.sw__item { transition: none; }
.sw__row { transition: none; }
}.stage html, .stage body {
margin: 0;
overflow: hidden;
}
.stage .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);
}
.sw {
width: min(100%, 300px);
position: relative;
}
.sw__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.sw__item {
position: relative;
overflow: hidden;
border-radius: 8px;
box-sizing: border-box;
transition: height 300ms cubic-bezier(0.2, 0.8, 0.2, 1), margin-bottom 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sw__reveal {
position: absolute;
inset: 0;
background: #c2361a;
color: #fff7e6;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 12px;
opacity: 0;
box-sizing: border-box;
}
.sw__row {
position: relative;
background: #fff7e6;
border-radius: 8px;
padding: 12px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #17141a;
font-size: 12px;
touch-action: pan-y;
cursor: grab;
user-select: none;
}
.sw__del {
border: none;
background: transparent;
color: rgba(23, 20, 26, 0.45);
width: 22px;
height: 22px;
display: grid;
place-items: center;
cursor: pointer;
flex: 0 0 auto;
}
.sw__xic {
width: 16px;
height: 16px;
}
.sw__del .sw__xic {
width: 14px;
height: 14px;
}
.sw__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
margin: -1px;
padding: 0;
border: 0;
}
@media (max-width: 340px) {
.sw {
width: 100%;
}
.sw__list {
max-height: 130px;
overflow-y: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.sw__item {
transition: none;
}
.sw__row {
transition: none;
}
}(function () {
"use strict";
var SWIPE_DISMISS_RATIO = 0.32; // 카드 폭 대비 이 비율 이상 밀면 손을 떼도 지워진다
var SWIPE_VELOCITY_PX_MS = 0.6; // 이 속도(px/ms) 이상으로 빠르게 밀면 거리가 짧아도 지워진다
var NAMES = [
"결제가 승인됐어요", "새 댓글이 달렸어요", "회의 10분 전이에요",
"업로드가 끝났어요", "초대장이 도착했어요"
];
var root = document.getElementById("sw");
var list = document.getElementById("swList");
var sr = document.getElementById("swSr");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var synthetic = false;
function human() {
if (synthetic) return;
if (root.classList.contains("is-demo")) root.classList.remove("is-demo");
}
var XIC =
'<svg class="sw__xic" viewBox="0 0 256 256" aria-hidden="true" focusable="false">' +
'<g fill="currentColor"><path opacity=".2" d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16"/>' +
'<path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></g></svg>';
function dismiss(li, name) {
var h = li.getBoundingClientRect().height;
li.style.height = h + "px";
li.classList.add("sw__item--out");
requestAnimationFrame(function () {
li.style.height = "0px";
li.style.marginBottom = "0px";
});
window.setTimeout(function () {
if (li.parentNode) li.parentNode.removeChild(li);
sr.textContent = name + " 알림을 지웠습니다";
}, reduce ? 0 : 260);
}
function makeRow(name) {
var li = document.createElement("li");
li.className = "sw__item";
li.setAttribute("role", "listitem");
var reveal = document.createElement("div");
reveal.className = "sw__reveal";
reveal.innerHTML = XIC;
var row = document.createElement("div");
row.className = "sw__row";
row.textContent = name;
var delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.className = "sw__del";
delBtn.setAttribute("aria-label", name + " 알림 지우기");
delBtn.innerHTML = XIC;
delBtn.addEventListener("click", function () {
human();
dismiss(li, name);
});
row.appendChild(delBtn);
li.appendChild(reveal);
li.appendChild(row);
// 계산 겹: pointerdown 시각·좌표를 기록해두고 pointermove 로 실제 이동 거리(dx)를,
// pointerup 시점에는 총 이동 시간으로 나눈 속도(px/ms)를 계산한다. 거리 비율(threshold)
// 이나 속도 중 하나만 넘어도 지워진다 — 톡 치듯 빠르게 밀면 짧게 밀어도 지워지는 이유.
var dragging = false, startX = 0, startT = 0, dx = 0, width = 0;
row.addEventListener("pointerdown", function (e) {
human();
dragging = true;
startX = e.clientX;
startT = e.timeStamp;
dx = 0;
width = li.getBoundingClientRect().width;
row.style.transition = "none";
row.setPointerCapture(e.pointerId);
});
row.addEventListener("pointermove", function (e) {
if (!dragging) return;
dx = Math.min(0, e.clientX - startX);
row.style.transform = "translateX(" + dx + "px)";
var p = Math.min(1, Math.abs(dx) / width);
reveal.style.opacity = String(p);
});
function endDrag(e) {
if (!dragging) return;
dragging = false;
row.style.transition = "";
var dt = Math.max(1, e.timeStamp - startT);
var velocity = Math.abs(dx) / dt;
var ratio = Math.abs(dx) / width;
if (ratio > SWIPE_DISMISS_RATIO || velocity > SWIPE_VELOCITY_PX_MS) {
row.style.transform = "translateX(-100%)";
reveal.style.opacity = "1";
dismiss(li, name);
} else {
row.style.transform = "translateX(0px)";
reveal.style.opacity = "0";
}
}
row.addEventListener("pointerup", endDrag);
row.addEventListener("pointercancel", endDrag);
return li;
}
NAMES.forEach(function (name) { list.appendChild(makeRow(name)); });
if (reduce) {
var last = list.querySelector(".sw__item:last-child");
if (last) dismiss(last, NAMES[NAMES.length - 1]);
} else {
function loop() {
if (!root.classList.contains("is-demo")) return;
var first = list.querySelector(".sw__item");
if (!first) {
NAMES.forEach(function (name) { list.appendChild(makeRow(name)); });
window.setTimeout(loop, 900);
return;
}
synthetic = true;
var row = first.querySelector(".sw__row");
var width = first.getBoundingClientRect().width;
row.style.transform = "translateX(" + (-width * 0.55) + "px)";
first.querySelector(".sw__reveal").style.opacity = "1";
window.setTimeout(function () {
dismiss(first, first.textContent.replace(/알림 지우기$/, "").trim());
synthetic = false;
}, 380);
window.setTimeout(loop, 1100);
}
window.setTimeout(loop, 600);
}
})();// NotifSwipeDismissItem — vanilla 판(run/405/demo/04-.../index.html) 의 React 포트.
// 계산 겹: pointerdown 시각·좌표를 기록해두고 pointermove 로 실제 이동 거리(dx)를,
// pointerup 시점에는 총 이동 시간으로 나눈 속도(px/ms)를 계산한다. 거리 비율(threshold)이나
// 속도 중 하나만 넘어도 지워진다 — 톡 치듯 빠르게 밀면 짧게 밀어도 지워지는 이유. 목록은
// React 가 다시 그리지 않고 imperative 하게 행을 만들고 지운다 — vanilla 와 동일 동작.
import { useEffect, useRef, useState } from "react";
import styles from "./NotifSwipeDismissItem.module.scss";
type Box<T> = { current: T };
const SWIPE_DISMISS_RATIO = 0.32; // 카드 폭 대비 이 비율 이상 밀면 손을 떼도 지워진다
const SWIPE_VELOCITY_PX_MS = 0.6; // 이 속도(px/ms) 이상으로 빠르게 밀면 거리가 짧아도 지워진다
const NAMES = [
"결제가 승인됐어요", "새 댓글이 달렸어요", "회의 10분 전이에요",
"업로드가 끝났어요", "초대장이 도착했어요",
];
const XIC =
'<svg class="' + "__XIC__" + '" viewBox="0 0 256 256" aria-hidden="true" focusable="false">' +
'<g fill="currentColor"><path opacity=".2" d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16"/>' +
'<path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></g></svg>';
export default function NotifSwipeDismissItem() {
const [isDemo, setIsDemo] = useState(true);
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const synthRef = useRef(false) as unknown as Box<boolean>;
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const listRef = useRef(null) as unknown as Box<HTMLUListElement>;
const srRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const reduceRef = useRef(false) as unknown as Box<boolean>;
const loopTimerRef = useRef(0) as unknown as Box<number>;
function human() {
if (synthRef.current) return;
if (isDemoRef.current) { isDemoRef.current = false; setIsDemo(false); }
}
function dismiss(li: HTMLLIElement, name: string) {
const reduce = reduceRef.current;
const h = li.getBoundingClientRect().height;
li.style.height = h + "px";
li.classList.add(styles.itemOut);
requestAnimationFrame(() => {
li.style.height = "0px";
li.style.marginBottom = "0px";
});
window.setTimeout(() => {
if (li.parentNode) li.parentNode.removeChild(li);
if (srRef.current) srRef.current.textContent = name + " 알림을 지웠습니다";
}, reduce ? 0 : 260);
}
function makeRow(name: string): HTMLLIElement {
const li = document.createElement("li");
li.className = styles.item;
li.setAttribute("role", "listitem");
const reveal = document.createElement("div");
reveal.className = styles.reveal;
reveal.innerHTML = XIC.replace("__XIC__", styles.xic);
const row = document.createElement("div");
row.className = styles.row;
row.textContent = name;
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.className = styles.del;
delBtn.setAttribute("aria-label", name + " 알림 지우기");
delBtn.innerHTML = XIC.replace("__XIC__", styles.xic);
delBtn.addEventListener("click", () => {
human();
dismiss(li, name);
});
row.appendChild(delBtn);
li.appendChild(reveal);
li.appendChild(row);
let dragging = false, startX = 0, startT = 0, dx = 0, width = 0;
row.addEventListener("pointerdown", (e: PointerEvent) => {
human();
dragging = true;
startX = e.clientX;
startT = e.timeStamp;
dx = 0;
width = li.getBoundingClientRect().width;
row.style.transition = "none";
row.setPointerCapture(e.pointerId);
});
row.addEventListener("pointermove", (e: PointerEvent) => {
if (!dragging) return;
dx = Math.min(0, e.clientX - startX);
row.style.transform = "translateX(" + dx + "px)";
const p = Math.min(1, Math.abs(dx) / width);
reveal.style.opacity = String(p);
});
function endDrag(e: PointerEvent) {
if (!dragging) return;
dragging = false;
row.style.transition = "";
const dt = Math.max(1, e.timeStamp - startT);
const velocity = Math.abs(dx) / dt;
const ratio = Math.abs(dx) / width;
if (ratio > SWIPE_DISMISS_RATIO || velocity > SWIPE_VELOCITY_PX_MS) {
row.style.transform = "translateX(-100%)";
reveal.style.opacity = "1";
dismiss(li, name);
} else {
row.style.transform = "translateX(0px)";
reveal.style.opacity = "0";
}
}
row.addEventListener("pointerup", endDrag);
row.addEventListener("pointercancel", endDrag);
return li;
}
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
reduceRef.current = reduce;
const list = listRef.current;
if (!list) return;
NAMES.forEach((name) => { list.appendChild(makeRow(name)); });
if (reduce) {
const last = list.querySelector("li:last-child") as HTMLLIElement | null;
if (last) dismiss(last, NAMES[NAMES.length - 1]);
} else {
function loop() {
if (!isDemoRef.current || !list) return;
const first = list.querySelector("li") as HTMLLIElement | null;
if (!first) {
NAMES.forEach((name) => { list.appendChild(makeRow(name)); });
loopTimerRef.current = window.setTimeout(loop, 900);
return;
}
synthRef.current = true;
const row = first.querySelector("." + styles.row) as HTMLElement | null;
const revealEl = first.querySelector("." + styles.reveal) as HTMLElement | null;
const width = first.getBoundingClientRect().width;
if (row) row.style.transform = "translateX(" + -width * 0.55 + "px)";
if (revealEl) revealEl.style.opacity = "1";
window.setTimeout(() => {
dismiss(first, (first.textContent || "").replace(/알림 지우기$/, "").trim());
synthRef.current = false;
}, 380);
loopTimerRef.current = window.setTimeout(loop, 1100);
}
loopTimerRef.current = window.setTimeout(loop, 600);
}
return () => { window.clearTimeout(loopTimerRef.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.sw + " " + (isDemo ? styles.isDemo : "")} ref={rootRef}>
<ul className={styles.list} role="list" ref={listRef} />
<p className={styles.sr} aria-live="polite" ref={srRef} />
</div>
</main>
);
}04 이동 거리 값을 넘기면 사라지는 스와이프 항목 — 측정 누적면적 38.515% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 알림 애니메이션 9가지 — 알림 패널 뱃지 정리
이 데모가 속한 글
css 알림 애니메이션 9가지 — 알림 패널 뱃지 정리css 알림 애니메이션 아홉 가지를 한 패널에 모았다. 「알림이 몇 개 남았나요?」 라는 질문에 이 패널은 미확인 값 하나로 답한다. 3번은 그 값이 0이 되는 순간 뱃지를 고리 모양으로 되돌리고, 6번은 같은 값이 이 글의 데모 전부 보기


