01トースト通知キュー — 積まれた順番の値から消える時点を計算する通知リスト
複数の通知が同時に積まれた状態になると、それぞれの表示時間に積まれた順番の値を加えて消える時点を求め直し、1つが先に消えると後ろの通知の位置の値もその分詰まります。
使いどころフォーム送信・保存完了の通知、複数の処理が重なるダッシュボード
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 オリジナル · SCSS と React は zip に同梱 · マイクロインタラクション UIパターン9選 — トーストからスクロールスパイまで
このデモが属する記事
マイクロインタラクション UIパターン9選 — トーストからスクロールスパイまでマイクロインタラクションは、9つのうち1つは通知が積まれた順番の値から消える時点を求め直し後ろの通知の位置を詰め、1つはスクロールした位置で各見出しの重なり比率の値を比べて活性項目を変え、もう1つは入力の値が変わるたびに待機時間の値を最初か この記事のデモをすべて見る


