05밀림 값만큼 옆으로 비켜나는 스누즈 항목
스누즈 버튼을 누르면 그 항목만 밀림 거리 값만큼 옆으로 이동하고, 남은 알림 개수 값에서 하나를 빼 타이머 라벨을 다시 그린다.
쓰임나중에 확인할 알림 미루기 버튼
NOTIF_SNOOZE_SLIDE_PXDURATIONSsnoozeOut

<main class="stage">
<div class="sn is-demo" id="sn">
<div class="sn__head">
<span class="sn__headLabel">알림</span>
<span class="sn__tray" id="snTray">스누즈 0건</span>
</div>
<ul class="sn__list" id="snList" role="list"></ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html snoozeOut() 이 NOTIF_SNOOZE_SLIDE_PX 예비 이동 뒤 li 의 실제 폭을
// getBoundingClientRect 로 재서 그만큼 더 밀어 화면 밖으로 내보낸다 — 남은 스누즈 건수에
// 따라 DURATIONS 배열에서 다음 라벨을 골라 다시 그린다.
.stage { @include stage($stage-ink); }
.sn {
width: min(100%, 300px);
display: flex;
flex-direction: column;
gap: $sp-3;
}
.sn__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
}
.sn__headLabel { font-size: 13px; font-weight: 700; color: $subject-cream; }
.sn__tray {
font-size: 10px;
color: $subject-cream;
background: rgba($subject-cream, .12);
border-radius: $r-pill;
padding: $sp-1 $sp-3;
}
.sn__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-1;
overflow: hidden;
}
.sn__item {
overflow: hidden;
border-radius: $r-sm;
box-sizing: border-box;
transition: height $dur-quick $ease-out, margin-bottom $dur-quick $ease-out;
}
.sn__row {
display: flex;
align-items: center;
gap: $sp-2;
background: $tick-panel;
border-radius: $r-sm;
padding: $sp-2 $sp-3;
box-sizing: border-box;
transition: transform $dur-base $ease-spring;
}
.sn__name { flex: 1 1 auto; font-size: 12px; color: $subject-cream; }
.sn__label {
font-size: 10px;
color: $subject-blue-light;
min-width: 44px;
text-align: right;
}
.sn__btn {
border: none;
background: rgba($subject-cream, .1);
color: $subject-cream;
width: 26px;
height: 26px;
border-radius: $r-pill;
display: grid;
place-items: center;
cursor: pointer;
flex: 0 0 auto;
}
.sn__ic { width: 14px; height: 14px; }
@media (max-width: 340px) {
.sn { width: 100%; }
.sn__label { display: none; }
.sn__list { max-height: 130px; overflow-y: auto; }
}
@media (prefers-reduced-motion: reduce) {
.sn__item { transition: none; }
.sn__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sn {
width: min(100%, 300px);
display: flex;
flex-direction: column;
gap: 12px;
}
.sn__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sn__headLabel {
font-size: 13px;
font-weight: 700;
color: #fff7e6;
}
.sn__tray {
font-size: 10px;
color: #fff7e6;
background: rgba(255, 247, 230, 0.12);
border-radius: 999px;
padding: 4px 12px;
}
.sn__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
}
.sn__item {
overflow: hidden;
border-radius: 8px;
box-sizing: border-box;
transition: height 220ms ease-out, margin-bottom 220ms ease-out;
}
.sn__row {
display: flex;
align-items: center;
gap: 8px;
background: #231f28;
border-radius: 8px;
padding: 8px 12px;
box-sizing: border-box;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sn__name {
flex: 1 1 auto;
font-size: 12px;
color: #fff7e6;
}
.sn__label {
font-size: 10px;
color: #5b8cf8;
min-width: 44px;
text-align: right;
}
.sn__btn {
border: none;
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
width: 26px;
height: 26px;
border-radius: 999px;
display: grid;
place-items: center;
cursor: pointer;
flex: 0 0 auto;
}
.sn__ic {
width: 14px;
height: 14px;
}
@media (max-width: 340px) {
.sn {
width: 100%;
}
.sn__label {
display: none;
}
.sn__list {
max-height: 130px;
overflow-y: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.sn__item {
transition: none;
}
.sn__row {
transition: none;
}
}(function () {
"use strict";
var NOTIF_SNOOZE_SLIDE_PX = 40; // 버튼을 누른 순간 먼저 밀려나는 예비 이동 거리(그 뒤 실측 폭만큼 마저 나간다)
var DURATIONS = ["10분 뒤", "30분 뒤", "1시간 뒤"];
var NAMES = [
"결제가 승인됐어요", "새 댓글이 달렸어요", "회의 10분 전이에요",
"업로드가 끝났어요", "초대장이 도착했어요"
];
var CLOCK_IC =
'<svg class="sn__ic" viewBox="0 0 256 256" aria-hidden="true" focusable="false">' +
'<g fill="currentColor"><path opacity=".2" d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96"/>' +
'<path d="M232 136.66A104.12 104.12 0 1 1 119.34 24a8 8 0 0 1 1.32 16A88.12 88.12 0 1 0 216 135.34a8 8 0 0 1 16 1.32M120 72v56a8 8 0 0 0 8 8h56a8 8 0 0 0 0-16h-48V72a8 8 0 0 0-16 0m40-24a12 12 0 1 0-12-12a12 12 0 0 0 12 12m36 24a12 12 0 1 0-12-12a12 12 0 0 0 12 12m24 36a12 12 0 1 0-12-12a12 12 0 0 0 12 12"/></g></svg>';
var root = document.getElementById("sn");
var list = document.getElementById("snList");
var tray = document.getElementById("snTray");
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 snoozed = 0;
function makeRow(name) {
var li = document.createElement("li");
li.className = "sn__item";
li.setAttribute("role", "listitem");
var row = document.createElement("div");
row.className = "sn__row";
var nameEl = document.createElement("span");
nameEl.className = "sn__name";
nameEl.textContent = name;
var label = document.createElement("span");
label.className = "sn__label";
var btn = document.createElement("button");
btn.type = "button";
btn.className = "sn__btn";
btn.setAttribute("aria-label", name + " 스누즈");
btn.innerHTML = CLOCK_IC;
row.appendChild(nameEl);
row.appendChild(label);
row.appendChild(btn);
li.appendChild(row);
// 계산 겹: 버튼을 누르면 먼저 NOTIF_SNOOZE_SLIDE_PX 만큼만 밀어 "눌렸다"는 반응을 준 뒤,
// 다음 단계에서 li 의 실제 폭(getBoundingClientRect)을 실측해 그 값만큼 마저 밀어 화면
// 밖으로 내보낸다 — 화면 폭이 얼마든 카드가 완전히 빠져나가는 거리를 그때그때 계산한다.
btn.addEventListener("click", function () {
human();
snoozeOut(li, row, label, name);
});
return li;
}
function snoozeOut(li, row, label, name) {
var durIdx = snoozed % DURATIONS.length;
label.textContent = DURATIONS[durIdx];
row.style.transform = "translateX(-" + NOTIF_SNOOZE_SLIDE_PX + "px)";
var width = li.getBoundingClientRect().width;
window.setTimeout(function () {
row.style.transform = "translateX(-" + (NOTIF_SNOOZE_SLIDE_PX + width) + "px)";
var h = li.getBoundingClientRect().height;
li.style.height = h + "px";
window.setTimeout(function () {
li.style.height = "0px";
li.style.marginBottom = "0px";
}, reduce ? 0 : 40);
window.setTimeout(function () {
if (li.parentNode) li.parentNode.removeChild(li);
}, reduce ? 0 : 320);
}, reduce ? 0 : 150);
snoozed++;
tray.textContent = "스누즈 " + snoozed + "건 · " + DURATIONS[durIdx];
}
var rows = [];
NAMES.forEach(function (name) {
var li = makeRow(name);
rows.push(li);
list.appendChild(li);
});
if (reduce) {
var first = list.querySelector(".sn__item");
if (first) {
var row0 = first.querySelector(".sn__row");
var label0 = first.querySelector(".sn__label");
snoozeOut(first, row0, label0, NAMES[0]);
}
} else {
var idx = 0;
function loop() {
if (!root.classList.contains("is-demo")) return;
var first = list.querySelector(".sn__item");
if (!first) {
NAMES.forEach(function (name) {
var li = makeRow(name);
list.appendChild(li);
});
snoozed = 0;
tray.textContent = "스누즈 0건";
window.setTimeout(loop, 900);
return;
}
synthetic = true;
var row = first.querySelector(".sn__row");
var label = first.querySelector(".sn__label");
var name = first.querySelector(".sn__name").textContent;
snoozeOut(first, row, label, name);
synthetic = false;
window.setTimeout(loop, 1200);
}
window.setTimeout(loop, 600);
}
})();// NotifSnoozeSlideOut — vanilla 판(run/405/demo/05-.../index.html) 의 React 포트.
// 계산 겹: 버튼을 누르면 먼저 NOTIF_SNOOZE_SLIDE_PX 만큼만 밀어 "눌렸다"는 반응을 준 뒤,
// 다음 단계에서 li 의 실제 폭(getBoundingClientRect)을 실측해 그 값만큼 마저 밀어 화면
// 밖으로 내보낸다 — 화면 폭이 얼마든 카드가 완전히 빠져나가는 거리를 그때그때 계산한다.
import { useEffect, useRef, useState } from "react";
import styles from "./NotifSnoozeSlideOut.module.scss";
type Box<T> = { current: T };
const NOTIF_SNOOZE_SLIDE_PX = 40; // 버튼을 누른 순간 먼저 밀려나는 예비 이동 거리(그 뒤 실측 폭만큼 마저 나간다)
const DURATIONS = ["10분 뒤", "30분 뒤", "1시간 뒤"];
const NAMES = [
"결제가 승인됐어요", "새 댓글이 달렸어요", "회의 10분 전이에요",
"업로드가 끝났어요", "초대장이 도착했어요",
];
const CLOCK_IC =
'<svg class="' + "__IC__" + '" viewBox="0 0 256 256" aria-hidden="true" focusable="false">' +
'<g fill="currentColor"><path opacity=".2" d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96"/>' +
'<path d="M232 136.66A104.12 104.12 0 1 1 119.34 24a8 8 0 0 1 1.32 16A88.12 88.12 0 1 0 216 135.34a8 8 0 0 1 16 1.32M120 72v56a8 8 0 0 0 8 8h56a8 8 0 0 0 0-16h-48V72a8 8 0 0 0-16 0m40-24a12 12 0 1 0-12-12a12 12 0 0 0 12 12m36 24a12 12 0 1 0-12-12a12 12 0 0 0 12 12m24 36a12 12 0 1 0-12-12a12 12 0 0 0 12 12"/></g></svg>';
export default function NotifSnoozeSlideOut() {
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 trayRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const reduceRef = useRef(false) as unknown as Box<boolean>;
const snoozedRef = useRef(0) as unknown as Box<number>;
const loopTimerRef = useRef(0) as unknown as Box<number>;
function human() {
if (synthRef.current) return;
if (isDemoRef.current) { isDemoRef.current = false; setIsDemo(false); }
}
function snoozeOut(li: HTMLLIElement, row: HTMLElement, label: HTMLElement, _name: string) {
const reduce = reduceRef.current;
const durIdx = snoozedRef.current % DURATIONS.length;
label.textContent = DURATIONS[durIdx];
row.style.transform = "translateX(-" + NOTIF_SNOOZE_SLIDE_PX + "px)";
const width = li.getBoundingClientRect().width;
window.setTimeout(() => {
row.style.transform = "translateX(-" + (NOTIF_SNOOZE_SLIDE_PX + width) + "px)";
const h = li.getBoundingClientRect().height;
li.style.height = h + "px";
window.setTimeout(() => {
li.style.height = "0px";
li.style.marginBottom = "0px";
}, reduce ? 0 : 40);
window.setTimeout(() => {
if (li.parentNode) li.parentNode.removeChild(li);
}, reduce ? 0 : 320);
}, reduce ? 0 : 150);
snoozedRef.current++;
if (trayRef.current) trayRef.current.textContent = "스누즈 " + snoozedRef.current + "건 · " + DURATIONS[durIdx];
}
function makeRow(name: string): HTMLLIElement {
const li = document.createElement("li");
li.className = styles.item;
li.setAttribute("role", "listitem");
const row = document.createElement("div");
row.className = styles.row;
const nameEl = document.createElement("span");
nameEl.className = styles.name;
nameEl.textContent = name;
const label = document.createElement("span");
label.className = styles.label;
const btn = document.createElement("button");
btn.type = "button";
btn.className = styles.btn;
btn.setAttribute("aria-label", name + " 스누즈");
btn.innerHTML = CLOCK_IC.replace("__IC__", styles.ic);
row.appendChild(nameEl);
row.appendChild(label);
row.appendChild(btn);
li.appendChild(row);
btn.addEventListener("click", () => {
human();
snoozeOut(li, row, label, name);
});
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 first = list.querySelector("li") as HTMLLIElement | null;
if (first) {
const row0 = first.querySelector("." + styles.row) as HTMLElement;
const label0 = first.querySelector("." + styles.label) as HTMLElement;
snoozeOut(first, row0, label0, NAMES[0]);
}
} 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)); });
snoozedRef.current = 0;
if (trayRef.current) trayRef.current.textContent = "스누즈 0건";
loopTimerRef.current = window.setTimeout(loop, 900);
return;
}
synthRef.current = true;
const row = first.querySelector("." + styles.row) as HTMLElement;
const label = first.querySelector("." + styles.label) as HTMLElement;
const nameEl = first.querySelector("." + styles.name) as HTMLElement;
const name = nameEl ? nameEl.textContent || "" : "";
snoozeOut(first, row, label, name);
synthRef.current = false;
loopTimerRef.current = window.setTimeout(loop, 1200);
}
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.sn + " " + (isDemo ? styles.isDemo : "")} ref={rootRef}>
<div className={styles.head}>
<span className={styles.headLabel}>알림</span>
<span className={styles.tray} ref={trayRef}>스누즈 0건</span>
</div>
<ul className={styles.list} role="list" ref={listRef} />
</div>
</main>
);
}05 밀림 값만큼 옆으로 비켜나는 스누즈 항목 — 측정 누적면적 41.176% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 알림 애니메이션 9가지 — 알림 패널 뱃지 정리
이 데모가 속한 글
css 알림 애니메이션 9가지 — 알림 패널 뱃지 정리css 알림 애니메이션 아홉 가지를 한 패널에 모았다. 「알림이 몇 개 남았나요?」 라는 질문에 이 패널은 미확인 값 하나로 답한다. 3번은 그 값이 0이 되는 순간 뱃지를 고리 모양으로 되돌리고, 6번은 같은 값이 이 글의 데모 전부 보기


