01토스트 알림 큐 — 쌓인 순서 값으로 사라지는 시점을 계산하는 알림 목록
여러 알림이 동시에 쌓인 상태가 되면 각자의 표시 시간에 쌓인 순서 값을 더해 사라지는 시점을 다시 계산하고, 하나가 먼저 사라지면 뒤에 놓인 알림들의 위치 값도 그만큼 당겨집니다.
쓰임폼 제출·저장 완료 알림, 여러 작업이 겹치는 대시보드
STACK_STEP_MSMAX_ROWS--i

<main class="stage">
<div class="tq is-demo" id="tq">
<button class="tq__add" id="tqAdd" type="button">알림 추가</button>
<ul class="tq__list" id="tqList" aria-live="polite"></ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 토스트 알림 큐 — 위치는 --i(쌓인 순서 값)로만 정해진다. 하나가 사라지면 JS 가 남은 항목의
// --i 를 다시 매기고, transition 이 그 값 변화를 translateY 이동으로 그린다(순서 재계산 → 위치 재계산).
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-mint;
$row-h: 40px;
$row-step: 46px;
@include stage($stage-ink);
.tq {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $sp-4;
width: min(100%, 300px);
--row-step: #{$row-step};
--row-h: #{$row-h};
}
.tq__add {
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-5;
font-family: inherit;
font-weight: 700;
font-size: 12px;
color: $stage-ink;
background: $color;
cursor: pointer;
}
.tq__list {
position: relative;
list-style: none;
margin: 0;
padding: 0;
width: 100%;
height: calc(var(--row-step, #{$row-step}) * 3);
}
.tq__row {
position: absolute;
left: 0;
top: 0;
display: flex;
align-items: center;
gap: $sp-2;
box-sizing: border-box;
width: 100%;
height: var(--row-h, #{$row-h});
padding: 0 $sp-4;
border-radius: $r-control;
background: rgba(255, 255, 255, .08);
color: $stage-paper;
transform: translateY(calc(var(--i, 0) * var(--row-step, #{$row-step})));
opacity: 1;
transition: transform $duration $easing, opacity $duration $easing;
cursor: pointer;
}
.tq__row--out {
opacity: 0;
transform: translateY(calc(var(--i, 0) * var(--row-step, #{$row-step}) + 10px));
}
.tq__dot {
width: $sp-2;
height: $sp-2;
border-radius: 50%;
background: $color;
flex: 0 0 auto;
}
.tq__text {
font-weight: 600;
font-size: 12px;
line-height: 1.4;
}
@media (max-width: 340px) {
.tq { gap: $sp-3; --row-step: 36px; --row-h: 32px; }
.tq__row { padding: 0 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.tq__row { transition: none !important; }
}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);
}
.tq {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
width: min(100%, 300px);
--row-step: 46px;
--row-h: 40px;
}
.tq__add {
border: 0;
border-radius: 999px;
padding: 8px 20px;
font-family: inherit;
font-weight: 700;
font-size: 12px;
color: #17141a;
background: #4cd4a6;
cursor: pointer;
}
.tq__list {
position: relative;
list-style: none;
margin: 0;
padding: 0;
width: 100%;
height: calc(var(--row-step, 46px) * 3);
}
.tq__row {
position: absolute;
left: 0;
top: 0;
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
width: 100%;
height: var(--row-h, 40px);
padding: 0 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
transform: translateY(calc(var(--i, 0) * var(--row-step, 46px)));
opacity: 1;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
cursor: pointer;
}
.tq__row--out {
opacity: 0;
transform: translateY(calc(var(--i, 0) * var(--row-step, 46px) + 10px));
}
.tq__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #4cd4a6;
flex: 0 0 auto;
}
.tq__text {
font-weight: 600;
font-size: 12px;
line-height: 1.4;
}
@media (max-width: 340px) {
.tq {
gap: 12px;
--row-step: 36px;
--row-h: 32px;
}
.tq__row {
padding: 0 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.tq__row {
transition: none !important;
}
}(function () {
var root = document.getElementById('tq');
var list = document.getElementById('tqList');
var addBtn = document.getElementById('tqAdd');
var MESSAGES = ['저장되었습니다', '전송 완료', '변경 사항 반영됨', '동기화 완료'];
var STACK_STEP_MS = 900; // 쌓인 순서 값마다 표시 시간에 더해지는 값
var BASE_MS = 1500;
var MAX_ROWS = 3;
var seq = 0;
var items = [];
var autoplay = null;
// 계산 겹: 표시 시간 = 기본 시간 + 쌓인 순서 값 × 간격. 하나가 사라지면 남은 항목의 순서 값을
// 다시 매겨 위치(translateY)를 그 값으로 다시 계산한다 — 단순 reflow 가 아니라 순서를 다시 센다.
function reindex() {
items.forEach(function (item, i) {
item.el.style.setProperty('--i', String(i));
});
}
function remove(item) {
var i = items.indexOf(item);
if (i === -1) { return; }
items.splice(i, 1);
item.el.classList.add('tq__row--out');
clearTimeout(item.timer);
setTimeout(function () { item.el.remove(); }, 240);
reindex();
}
function add() {
var el = document.createElement('li');
el.className = 'tq__row';
el.style.setProperty('--i', String(items.length));
var text = MESSAGES[seq % MESSAGES.length];
seq += 1;
var dot = document.createElement('span');
dot.className = 'tq__dot';
var span = document.createElement('span');
span.className = 'tq__text';
span.textContent = text;
el.appendChild(dot);
el.appendChild(span);
list.appendChild(el);
var item = { el: el };
items.push(item);
if (items.length > MAX_ROWS) { remove(items[0]); }
var order = items.indexOf(item);
var dismissAfter = BASE_MS + order * STACK_STEP_MS;
item.timer = setTimeout(function () { remove(item); }, dismissAfter);
el.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); remove(item); });
}
function stopAutoplay() {
if (autoplay) { clearInterval(autoplay); autoplay = null; }
}
addBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
add();
});
// 자동 루프 — 실사용 리스너와 같은 add()/remove() 를 그대로 반복 호출한다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
autoplay = setInterval(add, 620);
add();
}
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ToastQueue.module.scss";
// 토스트 알림 큐 — 쌓인 순서 값(order)으로 소멸 시점을 다시 계산한다.
// 계산 겹: order * STACK_STEP_MS 만큼 뒤로 갈수록 더 오래 떠 있는다. 실사용 클릭 리스너 있음.
const MESSAGES = ["저장되었습니다", "전송 완료", "변경 사항 반영됨", "동기화 완료"];
const STACK_STEP_MS = 900;
const BASE_MS = 1500;
const MAX_ROWS = 3;
type Item = { id: number; text: string; out: boolean };
export default function ToastQueue() {
const rootRef = useRef<HTMLDivElement>(null);
const [items, setItems] = useState<Item[]>([]);
const seqRef = useRef(0);
const timersRef = useRef<{ [id: number]: number }>({});
const autoplayRef = useRef<number | null>(null);
const itemsRef = useRef<Item[]>([]);
itemsRef.current = items;
function remove(id: number) {
const timers = timersRef.current ?? {};
window.clearTimeout(timers[id]);
delete timers[id];
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, out: true } : it)));
window.setTimeout(() => {
setItems((prev) => prev.filter((it) => it.id !== id));
}, 240);
}
function add() {
const id = seqRef.current ?? 0;
const text = MESSAGES[id % MESSAGES.length];
seqRef.current = id + 1;
setItems((prev) => {
const next = [...prev, { id, text, out: false }];
if (next.length > MAX_ROWS) {
const first = next[0];
window.setTimeout(() => remove(first.id), 0);
}
return next;
});
const order = (itemsRef.current ?? []).filter((it) => !it.out).length;
const dismissAfter = BASE_MS + order * STACK_STEP_MS;
const timers = timersRef.current ?? {};
timers[id] = window.setTimeout(() => remove(id), dismissAfter);
}
function stopAutoplay() {
if (autoplayRef.current) {
window.clearInterval(autoplayRef.current);
autoplayRef.current = null;
}
}
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
autoplayRef.current = window.setInterval(add, 620);
add();
}
return () => {
stopAutoplay();
const timers = timersRef.current ?? {};
Object.keys(timers).forEach((k) => window.clearTimeout(timers[Number(k)]));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function onAddClick() {
rootRef.current?.classList.remove(styles["is-demo"]);
stopAutoplay();
add();
}
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.tq} ${styles["is-demo"]}`} id="tq">
<button className={styles.tq__add} type="button" onClick={onAddClick}>알림 추가</button>
<ul className={styles.tq__list} aria-live="polite">
{items.map((it, i) => (
<li
key={it.id}
className={`${styles.tq__row} ${it.out ? styles["tq__row--out"] : ""}`}
style={{ "--i": i } as CSSProperties}
onClick={() => {
rootRef.current?.classList.remove(styles["is-demo"]);
stopAutoplay();
remove(it.id);
}}
>
<span className={styles.tq__dot} />
<span className={styles.tq__text}>{it.text}</span>
</li>
))}
</ul>
</div>
</main>
);
}01 토스트 알림 큐 — 쌓인 순서 값으로 사라지는 시점을 계산하는 알림 목록 — 측정 누적면적 19.387% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 마이크로 인터랙션 사이트 UI 패턴 9종 — 토스트부터 스크롤 스파이까지
이 데모가 속한 글
마이크로 인터랙션 사이트 UI 패턴 9종 — 토스트부터 스크롤 스파이까지마이크로 인터랙션 사이트에 흔한 아홉 개 중 하나는 알림이 쌓인 순서 값으로 사라지는 시점을 다시 구해 뒤 알림 위치를 당기고, 하나는 스크롤된 위치에서 각 제목의 겹침 비율 값을 비교해 활성 항목을 바꾸며, 또 하 이 글의 데모 전부 보기


