トースト通知 UI 9選 — 積み上がるスタック実装
トースト通知 ui とは、画面の隅に一瞬だけ出て消える知らせをどう積み、どう片づけるかを決める仕組みのことです。9つともライブラリなしでバニラ JS と SCSS だけで動き、最初の1つは新しい通知が前のカードを押し上げる動きだけを扱います。
01 toast-stack-push-up
03 toast-stack-progress-pause
04 toast-stack-swipe-off
05 toast-stack-undo-action
06 toast-stack-promise-morph
07 toast-stack-collapse-group
08 toast-stack-position-switch
09 toast-stack-live-region
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 押し上がるスタック
- 02 種類ごとのアイコンと色
- 03 残り時間バーと一時停止
- 04 スワイプで飛ばす
- 05 本当に元に戻すボタン
- 06 読み込みから結果へ変わるトースト
- 07 三件を超えると畳まれる束
- 08 六か所の表示位置切り替え
- 09 読み上げ順を分けるライブリージョン
9つの並びは人気順ではなく、通知1件が画面に出てから消えるまでの時間の流れに沿っています。まず入ってきて席を取り(01)、何が起きたのかを色とアイコンで名乗り(02)、残り時間を見せながら待ちます(03)。次は片づける側で、指で払って飛ばし(04)、消しすぎたものを戻し(05)、結果が遅れて届く処理は同じ席のまま答えを差し替えます(06)。最後の3つは一度に何件も届いたときの話で、あふれたら束ねて畳み(07)、出す場所を6か所から選び(08)、画面を見ない人のために読み上げの順序を分けます(09)。通知 スタックの骨組みは9つとも共通です。スタックは高さを決めた箱の中に収まり、カードの上下移動はほとんど transform が受け持ちます。カードを top や margin で押すと、積み上がるたびにブラウザーがレイアウトを測り直すからです。通知より先に同意を得る必要がある場所はクッキー同意バナーUIで、通知が複数集まって格子になる画面はベントグリッドで扱います。
01押し上がるスタック
「通知 スタックとは」何が見えるか
新しいカードは一覧のいちばん下に入り、その上にいたカードが1段ずつ上がります。上がる途中が飛んで見えないように、差し込む前の位置を先に測り、差し込んだあとに元の位置へ貼り直してから1フレーム遅れて放します。

<main class="stage">
<div class="pu is-demo">
<div class="pu__stack">
<div class="pu__list" id="pu-list" role="region" aria-label="通知スタック" aria-live="polite">
<article class="pu__item">
<span class="pu__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
<span class="pu__msg">ファイルを保存しました</span>
</article>
<article class="pu__item">
<span class="pu__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg></span>
<span class="pu__msg">コメントが1件つきました</span>
</article>
<article class="pu__item">
<span class="pu__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M240 128a80 80 0 0 1-80 80H72A56 56 0 1 1 85.92 97.74v.1A80 80 0 0 1 240 128" opacity=".2"/><path d="M178.34 165.66L160 147.31V208a8 8 0 0 1-16 0v-60.69l-18.34 18.35a8 8 0 0 1-11.32-11.32l32-32a8 8 0 0 1 11.32 0l32 32a8 8 0 0 1-11.32 11.32M160 40a88.08 88.08 0 0 0-78.71 48.68A64 64 0 1 0 72 216h40a8 8 0 0 0 0-16H72a48 48 0 0 1 0-96c1.1 0 2.2 0 3.29.12A88 88 0 0 0 72 128a8 8 0 0 0 16 0a72 72 0 1 1 100.8 66a8 8 0 0 0 3.2 15.34a7.9 7.9 0 0 0 3.2-.68A88 88 0 0 0 160 40"/></g></svg></span>
<span class="pu__msg">アップロードが終わりました</span>
</article>
</div>
</div>
<button class="pu__go" type="button" id="pu-go">通知をもう1件送る</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.pu {
box-sizing: border-box;
width: min(408px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
--slot: 44px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
}
// 스택 상자 — 카드가 아래에서 들어오므로 넘치는 부분은 여기서 잘린다
.pu__stack {
position: relative;
overflow: hidden;
height: 140px;
border-radius: $r-card;
background: rgba(255, 255, 255, .05);
}
.pu__list {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
display: flex;
flex-direction: column-reverse;
justify-content: flex-start;
gap: $sp-2;
}
.pu__item {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
margin: 0;
padding: $sp-2 $sp-3;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
box-shadow: $shadow-press;
transition: transform $duration $easing;
}
.pu__ic {
display: inline-flex;
flex: 0 0 auto;
color: $color;
}
.pu__ic svg { display: block; }
.pu__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 12px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pu__go {
box-sizing: border-box;
margin: 0;
padding: $sp-2 $sp-4;
border: 0;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
font-family: inherit;
font-size: 12px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.pu__go:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// 사람이 누른 뒤: 새 카드만 아래에서 솟고, 밀려나는 카드는 위 transition 이 옮긴다
.pu__item.is-in {
animation-name: pu-in;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
@keyframes pu-in {
0% { opacity: 0; transform: translateY(40px); }
100% { opacity: 1; transform: translateY(0); }
}
// ── 자동 루프: 세 장이 차례로 들어오며 앞 카드를 한 칸씩 위로 민다. 90% 안에서 전부 빠진다 ──
.pu.is-demo .pu__item {
position: absolute;
left: 0;
right: 0;
bottom: 0;
transition: none;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.pu.is-demo .pu__item:nth-child(1) { animation-name: pu-a; }
.pu.is-demo .pu__item:nth-child(2) { animation-name: pu-b; }
.pu.is-demo .pu__item:nth-child(3) { animation-name: pu-c; }
// 퇴장은 샘플 프레임 사이(4.1667% 간격)에서 한 장씩 끊어 없앤다 — 셋이 한꺼번에 사라지면 그 빈 화면이 poster 로 뽑힌다
@keyframes pu-a {
0%, 4% { opacity: 0; transform: translateY(48px); }
12% { opacity: 1; transform: translateY(0); }
34% { opacity: 1; transform: translateY(0); }
42% { opacity: 1; transform: translateY(calc(var(--slot) * -1)); }
62% { opacity: 1; transform: translateY(calc(var(--slot) * -1)); }
70% { opacity: 1; transform: translateY(calc(var(--slot) * -2)); }
76% { opacity: 1; transform: translateY(calc(var(--slot) * -2)); }
78% { opacity: 0; transform: translateY(calc(var(--slot) * -2)); }
90%, 100% { opacity: 0; transform: translateY(48px); }
}
@keyframes pu-b {
0%, 30% { opacity: 0; transform: translateY(48px); }
40% { opacity: 1; transform: translateY(0); }
62% { opacity: 1; transform: translateY(0); }
70% { opacity: 1; transform: translateY(calc(var(--slot) * -1)); }
80% { opacity: 1; transform: translateY(calc(var(--slot) * -1)); }
82% { opacity: 0; transform: translateY(calc(var(--slot) * -1)); }
90%, 100% { opacity: 0; transform: translateY(48px); }
}
@keyframes pu-c {
0%, 58% { opacity: 0; transform: translateY(48px); }
68% { opacity: 1; transform: translateY(0); }
84% { opacity: 1; transform: translateY(0); }
86% { opacity: 0; transform: translateY(0); }
90%, 100% { opacity: 0; transform: translateY(48px); }
}
@media (max-width: 340px) {
.pu { --slot: 34px; }
.pu__stack { height: 104px; }
.pu__item { padding: $sp-1 $sp-2; }
.pu__msg { font-size: 11px; line-height: 16px; }
.pu__go { padding: $sp-1 $sp-3; font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.pu.is-demo .pu__item,
.pu__item.is-in { animation: none !important; opacity: 1; }
.pu.is-demo .pu__item:nth-child(1) { transform: translateY(calc(var(--slot) * -2)); }
.pu.is-demo .pu__item:nth-child(2) { transform: translateY(calc(var(--slot) * -1)); }
.pu.is-demo .pu__item:nth-child(3) { transform: translateY(0); }
.pu__item { transition: none; }
}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);
}
.pu {
box-sizing: border-box;
width: min(408px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
--slot: 44px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.pu__stack {
position: relative;
overflow: hidden;
height: 140px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
}
.pu__list {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
display: flex;
flex-direction: column-reverse;
justify-content: flex-start;
gap: 8px;
}
.pu__item {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 8px 12px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pu__ic {
display: inline-flex;
flex: 0 0 auto;
color: #2f6df6;
}
.pu__ic svg {
display: block;
}
.pu__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 12px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pu__go {
box-sizing: border-box;
margin: 0;
padding: 8px 16px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-size: 12px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.pu__go:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.pu__item.is-in {
animation-name: pu-in;
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
}
@keyframes pu-in {
0% {
opacity: 0;
transform: translateY(40px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.pu.is-demo .pu__item {
position: absolute;
left: 0;
right: 0;
bottom: 0;
transition: none;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.pu.is-demo .pu__item:nth-child(1) {
animation-name: pu-a;
}
.pu.is-demo .pu__item:nth-child(2) {
animation-name: pu-b;
}
.pu.is-demo .pu__item:nth-child(3) {
animation-name: pu-c;
}
@keyframes pu-a {
0%, 4% {
opacity: 0;
transform: translateY(48px);
}
12% {
opacity: 1;
transform: translateY(0);
}
34% {
opacity: 1;
transform: translateY(0);
}
42% {
opacity: 1;
transform: translateY(calc(var(--slot) * -1));
}
62% {
opacity: 1;
transform: translateY(calc(var(--slot) * -1));
}
70% {
opacity: 1;
transform: translateY(calc(var(--slot) * -2));
}
76% {
opacity: 1;
transform: translateY(calc(var(--slot) * -2));
}
78% {
opacity: 0;
transform: translateY(calc(var(--slot) * -2));
}
90%, 100% {
opacity: 0;
transform: translateY(48px);
}
}
@keyframes pu-b {
0%, 30% {
opacity: 0;
transform: translateY(48px);
}
40% {
opacity: 1;
transform: translateY(0);
}
62% {
opacity: 1;
transform: translateY(0);
}
70% {
opacity: 1;
transform: translateY(calc(var(--slot) * -1));
}
80% {
opacity: 1;
transform: translateY(calc(var(--slot) * -1));
}
82% {
opacity: 0;
transform: translateY(calc(var(--slot) * -1));
}
90%, 100% {
opacity: 0;
transform: translateY(48px);
}
}
@keyframes pu-c {
0%, 58% {
opacity: 0;
transform: translateY(48px);
}
68% {
opacity: 1;
transform: translateY(0);
}
84% {
opacity: 1;
transform: translateY(0);
}
86% {
opacity: 0;
transform: translateY(0);
}
90%, 100% {
opacity: 0;
transform: translateY(48px);
}
}
@media (max-width: 340px) {
.pu {
--slot: 34px;
}
.pu__stack {
height: 104px;
}
.pu__item {
padding: 4px 8px;
}
.pu__msg {
font-size: 11px;
line-height: 16px;
}
.pu__go {
padding: 4px 12px;
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.pu.is-demo .pu__item,
.pu__item.is-in {
animation: none !important;
opacity: 1;
}
.pu.is-demo .pu__item:nth-child(1) {
transform: translateY(calc(var(--slot) * -2));
}
.pu.is-demo .pu__item:nth-child(2) {
transform: translateY(calc(var(--slot) * -1));
}
.pu.is-demo .pu__item:nth-child(3) {
transform: translateY(0);
}
.pu__item {
transition: none;
}
}// 실제 동작: 새 카드를 맨 아래에 넣고, 위로 밀려나는 카드들을 FLIP 으로 제자리에 붙였다 놓는다
var root = document.querySelector('.pu');
var list = document.getElementById('pu-list');
var go = document.getElementById('pu-go');
var seeds = [].slice.call(list.children); // 원본 카드 세 장을 틀로 쓴다
var turn = 0;
function push() {
if (root.classList.contains('is-demo')) { root.classList.remove('is-demo'); list.innerHTML = ''; }
var before = [].map.call(list.children, function (el) { return el.getBoundingClientRect().top; });
var el = seeds[turn++ % seeds.length].cloneNode(true);
el.className = 'pu__item is-in';
list.insertBefore(el, list.firstChild);
[].forEach.call(list.children, function (node, i) {
if (!i) return;
var d = before[i - 1] - node.getBoundingClientRect().top; // 옛 위치 − 새 위치 = 되돌릴 거리
node.style.transition = 'none';
node.style.transform = 'translateY(' + d + 'px)';
});
requestAnimationFrame(function () { // 한 프레임 뒤에 풀어야 브라우저가 되돌린 위치를 시작점으로 본다
[].forEach.call(list.children, function (node, i) {
if (!i) return;
node.style.transition = '';
node.style.transform = '';
});
});
while (list.children.length > 3) list.removeChild(list.lastChild);
}
go.addEventListener('click', push);import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PushUpStack.module.scss";
export type PushUpStackProps = {
items?: string[];
label?: string;
regionLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// React 이벤트 타입은 'react' 에서 import 하지 않는다 — 포트 안에서 필요한 모양만 선언한다
type ClickEvt = { preventDefault: () => void };
export default function PushUpStack({
items = ["Saved the file", "Someone left a comment", "Upload finished"],
label = "Send one more",
regionLabel = "Notification stack",
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: PushUpStackProps) {
const [demo, setDemo] = useState(true);
const [rows, setRows] = useState<{ id: number; text: string }[]>([]);
const listRef = useRef<HTMLDivElement>(null);
const before = useRef<number[]>([]);
const seq = useRef<number[]>([0]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function push(e: ClickEvt) {
e.preventDefault();
const el = listRef.current;
before.current = el ? Array.from(el.children).map((c) => (c as HTMLElement).getBoundingClientRect().top) : [];
const n = (seq.current ?? [0])[0];
if (seq.current) seq.current[0] = n + 1;
setDemo(false);
setRows((v) => [{ id: n, text: items[n % items.length] }, ...v].slice(0, 3));
}
// FLIP — 새 줄이 들어가 레이아웃이 바뀐 **뒤**에 옛 위치로 되돌렸다가 한 프레임 뒤에 풀어 준다
useEffect(() => {
const el = listRef.current;
const old = before.current ?? [];
if (!el || !old.length) return;
const kids = Array.from(el.children) as HTMLElement[];
kids.forEach((node, i) => {
if (!i) return;
node.style.transition = "none";
node.style.transform = `translateY(${old[i - 1] - node.getBoundingClientRect().top}px)`;
});
requestAnimationFrame(() => {
kids.forEach((node, i) => {
if (!i) return;
node.style.transition = "";
node.style.transform = "";
});
});
before.current = [];
}, [rows]);
const shown = demo ? items.slice(0, 3).map((text, i) => ({ id: -1 - i, text })) : rows;
return (
<div className={`${styles.pu} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.stack}>
<div className={styles.list} ref={listRef} role="region" aria-label={regionLabel} aria-live="polite">
{shown.map((row) => (
<article key={row.id} className={`${styles.item} ${demo ? "" : styles.isIn}`}>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" /><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" /></g></svg>
</span>
<span className={styles.msg}>{row.text}</span>
</article>
))}
</div>
</div>
<button className={styles.go} type="button" onClick={push}>{label}</button>
</div>
);
}01 · 押し上がるスタック — 計測 累積面積 35.204% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
この4手順が FLIP で、ブラウザーが動きとして見るのは最後の1手だけです。
var before = [].map.call(list.children, function (el) { return el.getBoundingClientRect().top; });
list.insertBefore(el, list.firstChild);
[].forEach.call(list.children, function (node, i) {
if (!i) return;
var d = before[i - 1] - node.getBoundingClientRect().top; // 元の位置 − 今の位置 = 戻す距離
node.style.transition = 'none';
node.style.transform = 'translateY(' + d + 'px)';
});
requestAnimationFrame(function () { // 1フレーム遅らせないと、戻した位置が始点として見られない
[].forEach.call(list.children, function (node, i) {
if (!i) return;
node.style.transition = '';
node.style.transform = '';
});
});
02種類ごとのアイコンと色
画面で起きること
成功・警告・エラー・お知らせの4種類が1枚のカードの中に重なっていて、カードの data-kind の値ひとつがどの顔を出すかを決めます。自動再生では4つの顔が 0.5 秒ごとに入れ替わりますが、切り替わる瞬間を steps(1, end) で断ち切り、2つの顔が半透明で重なるコマをなくしました。
<main class="stage">
<div class="tk is-demo">
<div class="tk__box">
<div class="tk__card" id="tk-card" data-kind="ok" role="status" aria-live="polite">
<span class="tk__bar" aria-hidden="true"></span>
<span class="tk__face" data-kind="ok">
<span class="tk__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="26" height="26"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
<span class="tk__msg">保存が終わりました</span>
</span>
<span class="tk__face" data-kind="warn">
<span class="tk__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="26" height="26"><g fill="currentColor"><path d="M215.46 216H40.54c-12.62 0-20.54-13.21-14.41-23.91l87.46-151.87c6.3-11 22.52-11 28.82 0l87.46 151.87c6.13 10.7-1.79 23.91-14.41 23.91" opacity=".2"/><path d="M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72m-13.87 15.71a8.5 8.5 0 0 1-7.48 4.2H40.55a8.5 8.5 0 0 1-7.48-4.2a7.59 7.59 0 0 1 0-7.72l87.45-151.87a8.75 8.75 0 0 1 15 0l87.45 151.87a7.59 7.59 0 0 1-.04 7.72M120 144v-40a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg></span>
<span class="tk__msg">容量がほぼ埋まりました</span>
</span>
<span class="tk__face" data-kind="bad">
<span class="tk__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="26" height="26"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M165.66 101.66L139.31 128l26.35 26.34a8 8 0 0 1-11.32 11.32L128 139.31l-26.34 26.35a8 8 0 0 1-11.32-11.32L116.69 128l-26.35-26.34a8 8 0 0 1 11.32-11.32L128 116.69l26.34-26.35a8 8 0 0 1 11.32 11.32M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
<span class="tk__msg">送信に失敗しました</span>
</span>
<span class="tk__face" data-kind="info">
<span class="tk__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="26" height="26"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M144 176a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m88-48A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88m-92-32a12 12 0 1 0-12-12a12 12 0 0 0 12 12"/></g></svg></span>
<span class="tk__msg">新しいバージョンが出ました</span>
</span>
</div>
</div>
<div class="tk__row" role="group" aria-label="通知の種類を選ぶ">
<button class="tk__pick" type="button" data-pick="ok" aria-pressed="true">成功</button>
<button class="tk__pick" type="button" data-pick="warn" aria-pressed="false">警告</button>
<button class="tk__pick" type="button" data-pick="bad" aria-pressed="false">エラー</button>
<button class="tk__pick" type="button" data-pick="info" aria-pressed="false">お知らせ</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-mint;
@include stage($stage-yellow);
.tk {
box-sizing: border-box;
width: min(400px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.tk__box {
display: flex;
justify-content: center;
height: 64px;
}
.tk__card {
position: relative;
box-sizing: border-box;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: $r-card;
background: $subject-ink;
box-shadow: $shadow-raised;
}
// 왼쪽 색 띠 — 종류를 문구보다 먼저 알려 준다
.tk__bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6px;
background-color: $color;
}
.tk__face {
position: absolute;
left: 6px;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
gap: $sp-3;
padding: 0 $sp-4;
opacity: 0;
}
.tk__face[data-kind="ok"] { color: $subject-mint; }
.tk__face[data-kind="warn"] { color: $stage-yellow; }
.tk__face[data-kind="bad"] { color: $stage-orange; }
.tk__face[data-kind="info"] { color: $subject-sky; }
.tk__ic { display: inline-flex; flex: 0 0 auto; }
.tk__ic svg { display: block; }
.tk__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 13px;
line-height: 20px;
font-weight: 700;
color: $subject-cream;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// 사람이 고른 종류만 보이고, 색 띠도 그 얼굴의 색을 그대로 쓴다
.tk:not(.is-demo) .tk__card[data-kind="ok"] .tk__face[data-kind="ok"],
.tk:not(.is-demo) .tk__card[data-kind="warn"] .tk__face[data-kind="warn"],
.tk:not(.is-demo) .tk__card[data-kind="bad"] .tk__face[data-kind="bad"],
.tk:not(.is-demo) .tk__card[data-kind="info"] .tk__face[data-kind="info"] { opacity: 1; }
.tk:not(.is-demo) .tk__card[data-kind="ok"] .tk__bar { background-color: $subject-mint; }
.tk:not(.is-demo) .tk__card[data-kind="warn"] .tk__bar { background-color: $stage-yellow; }
.tk:not(.is-demo) .tk__card[data-kind="bad"] .tk__bar { background-color: $stage-orange; }
.tk:not(.is-demo) .tk__card[data-kind="info"] .tk__bar { background-color: $subject-sky; }
.tk__card.is-pop {
animation-name: tk-pop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
.tk__row {
display: flex;
gap: $sp-2;
justify-content: center;
}
.tk__pick {
box-sizing: border-box;
margin: 0;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: transparent;
color: $subject-ink;
font-family: inherit;
font-size: 12px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.tk__pick[aria-pressed="true"] { background: $subject-ink; color: $stage-yellow; }
.tk__pick:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// ── 자동 루프: 네 종류를 0.5초마다 한 프레임에 끊어 바꾼다. 크로스페이드로 섞으면 두 얼굴이 겹친 유령 프레임이 남는다 ──
.tk.is-demo .tk__face {
animation-name: tk-face;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tk.is-demo .tk__face[data-kind="ok"] { animation-delay: 0s; }
.tk.is-demo .tk__face[data-kind="warn"] { animation-delay: -1.5s; }
.tk.is-demo .tk__face[data-kind="bad"] { animation-delay: -1s; }
.tk.is-demo .tk__face[data-kind="info"] { animation-delay: -0.5s; }
@keyframes tk-face {
0% { opacity: 1; }
25% { opacity: 0; }
100% { opacity: 0; }
}
.tk.is-demo .tk__bar {
animation-name: tk-bar;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes tk-bar {
0% { background-color: $subject-mint; }
25% { background-color: $stage-yellow; }
50% { background-color: $stage-orange; }
75% { background-color: $subject-sky; }
100% { background-color: $subject-mint; }
}
// 아래 고르기 버튼도 같은 시계로 따라간다 — 카드만 바뀌면 무엇이 눌린 상태인지 어긋나 보인다
.tk.is-demo .tk__pick {
animation-name: tk-pick;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tk.is-demo .tk__pick[data-pick="ok"] { animation-delay: 0s; }
.tk.is-demo .tk__pick[data-pick="warn"] { animation-delay: -1.5s; }
.tk.is-demo .tk__pick[data-pick="bad"] { animation-delay: -1s; }
.tk.is-demo .tk__pick[data-pick="info"] { animation-delay: -0.5s; }
@keyframes tk-pick {
0% { background-color: $subject-ink; color: $stage-yellow; }
25% { background-color: rgba(255, 255, 255, 0); color: $subject-ink; }
100% { background-color: rgba(255, 255, 255, 0); color: $subject-ink; }
}
// 종류가 바뀌는 순간마다 카드가 한 번 눌렸다 펴진다 — 0.5초 주기라 2초 루프 안에 네 번 돈다
.tk.is-demo .tk__card {
animation-name: tk-pop;
animation-duration: 500ms;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes tk-pop {
0% { transform: scale(.94); }
34% { transform: scale(1.02); }
60% { transform: scale(1); }
100% { transform: scale(1); }
}
@media (max-width: 340px) {
.tk { gap: $sp-2; }
.tk__box { height: 52px; }
.tk__face { gap: $sp-2; padding: 0 $sp-3; }
.tk__msg { font-size: 11px; line-height: 16px; }
.tk__pick { padding: $sp-1 $sp-2; font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.tk.is-demo .tk__face,
.tk.is-demo .tk__bar,
.tk.is-demo .tk__card,
.tk.is-demo .tk__pick,
.tk__card.is-pop { animation: none !important; }
.tk.is-demo .tk__face[data-kind="ok"] { opacity: 1; }
}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);
}
.tk {
box-sizing: border-box;
width: min(400px, 88vw);
display: flex;
flex-direction: column;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.tk__box {
display: flex;
justify-content: center;
height: 64px;
}
.tk__card {
position: relative;
box-sizing: border-box;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.tk__bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6px;
background-color: #4cd4a6;
}
.tk__face {
position: absolute;
left: 6px;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
opacity: 0;
}
.tk__face[data-kind=ok] {
color: #4cd4a6;
}
.tk__face[data-kind=warn] {
color: #ffd23f;
}
.tk__face[data-kind=bad] {
color: #ff4d1f;
}
.tk__face[data-kind=info] {
color: #6ec8ff;
}
.tk__ic {
display: inline-flex;
flex: 0 0 auto;
}
.tk__ic svg {
display: block;
}
.tk__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 13px;
line-height: 20px;
font-weight: 700;
color: #fff7e6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tk:not(.is-demo) .tk__card[data-kind=ok] .tk__face[data-kind=ok],
.tk:not(.is-demo) .tk__card[data-kind=warn] .tk__face[data-kind=warn],
.tk:not(.is-demo) .tk__card[data-kind=bad] .tk__face[data-kind=bad],
.tk:not(.is-demo) .tk__card[data-kind=info] .tk__face[data-kind=info] {
opacity: 1;
}
.tk:not(.is-demo) .tk__card[data-kind=ok] .tk__bar {
background-color: #4cd4a6;
}
.tk:not(.is-demo) .tk__card[data-kind=warn] .tk__bar {
background-color: #ffd23f;
}
.tk:not(.is-demo) .tk__card[data-kind=bad] .tk__bar {
background-color: #ff4d1f;
}
.tk:not(.is-demo) .tk__card[data-kind=info] .tk__bar {
background-color: #6ec8ff;
}
.tk__card.is-pop {
animation-name: tk-pop;
animation-duration: 220ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
.tk__row {
display: flex;
gap: 8px;
justify-content: center;
}
.tk__pick {
box-sizing: border-box;
margin: 0;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: transparent;
color: #17141a;
font-family: inherit;
font-size: 12px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.tk__pick[aria-pressed=true] {
background: #17141a;
color: #ffd23f;
}
.tk__pick:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.tk.is-demo .tk__face {
animation-name: tk-face;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tk.is-demo .tk__face[data-kind=ok] {
animation-delay: 0s;
}
.tk.is-demo .tk__face[data-kind=warn] {
animation-delay: -1.5s;
}
.tk.is-demo .tk__face[data-kind=bad] {
animation-delay: -1s;
}
.tk.is-demo .tk__face[data-kind=info] {
animation-delay: -0.5s;
}
@keyframes tk-face {
0% {
opacity: 1;
}
25% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.tk.is-demo .tk__bar {
animation-name: tk-bar;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes tk-bar {
0% {
background-color: #4cd4a6;
}
25% {
background-color: #ffd23f;
}
50% {
background-color: #ff4d1f;
}
75% {
background-color: #6ec8ff;
}
100% {
background-color: #4cd4a6;
}
}
.tk.is-demo .tk__pick {
animation-name: tk-pick;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tk.is-demo .tk__pick[data-pick=ok] {
animation-delay: 0s;
}
.tk.is-demo .tk__pick[data-pick=warn] {
animation-delay: -1.5s;
}
.tk.is-demo .tk__pick[data-pick=bad] {
animation-delay: -1s;
}
.tk.is-demo .tk__pick[data-pick=info] {
animation-delay: -0.5s;
}
@keyframes tk-pick {
0% {
background-color: #17141a;
color: #ffd23f;
}
25% {
background-color: rgba(255, 255, 255, 0);
color: #17141a;
}
100% {
background-color: rgba(255, 255, 255, 0);
color: #17141a;
}
}
.tk.is-demo .tk__card {
animation-name: tk-pop;
animation-duration: 500ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes tk-pop {
0% {
transform: scale(0.94);
}
34% {
transform: scale(1.02);
}
60% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.tk {
gap: 8px;
}
.tk__box {
height: 52px;
}
.tk__face {
gap: 8px;
padding: 0 12px;
}
.tk__msg {
font-size: 11px;
line-height: 16px;
}
.tk__pick {
padding: 4px 8px;
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.tk.is-demo .tk__face,
.tk.is-demo .tk__bar,
.tk.is-demo .tk__card,
.tk.is-demo .tk__pick,
.tk__card.is-pop {
animation: none !important;
}
.tk.is-demo .tk__face[data-kind=ok] {
opacity: 1;
}
}// 실제 동작: 버튼을 누르면 카드의 data-kind 가 바뀌고, 그 값에 걸린 CSS 가 아이콘·색·문구를 한꺼번에 갈아 끼운다
var root = document.querySelector('.tk');
var card = document.getElementById('tk-card');
var picks = [].slice.call(document.querySelectorAll('.tk__pick'));
function pick(kind) {
root.classList.remove('is-demo');
card.setAttribute('data-kind', kind);
picks.forEach(function (b) { b.setAttribute('aria-pressed', String(b.getAttribute('data-pick') === kind)); });
card.classList.remove('is-pop');
void card.offsetWidth; // 같은 종류를 다시 눌러도 팝이 한 번 더 돌게 리플로를 강제한다
card.classList.add('is-pop');
}
picks.forEach(function (b) {
b.addEventListener('click', function () { pick(b.getAttribute('data-pick')); });
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ToastKinds.module.scss";
export type ToastKind = "ok" | "warn" | "bad" | "info";
export type ToastKindsProps = {
messages?: Record<ToastKind, string>;
picks?: Record<ToastKind, string>;
groupLabel?: string;
duration?: number;
easing?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
const ORDER: ToastKind[] = ["ok", "warn", "bad", "info"];
const ICON: Record<ToastKind, any> = {
ok: <g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" /><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" /></g>,
warn: <g fill="currentColor"><path d="M215.46 216H40.54c-12.62 0-20.54-13.21-14.41-23.91l87.46-151.87c6.3-11 22.52-11 28.82 0l87.46 151.87c6.13 10.7-1.79 23.91-14.41 23.91" opacity=".2" /><path d="M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72m-13.87 15.71a8.5 8.5 0 0 1-7.48 4.2H40.55a8.5 8.5 0 0 1-7.48-4.2a7.59 7.59 0 0 1 0-7.72l87.45-151.87a8.75 8.75 0 0 1 15 0l87.45 151.87a7.59 7.59 0 0 1-.04 7.72M120 144v-40a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12" /></g>,
bad: <g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" /><path d="M165.66 101.66L139.31 128l26.35 26.34a8 8 0 0 1-11.32 11.32L128 139.31l-26.34 26.35a8 8 0 0 1-11.32-11.32L116.69 128l-26.35-26.34a8 8 0 0 1 11.32-11.32L128 116.69l26.34-26.35a8 8 0 0 1 11.32 11.32M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" /></g>,
info: <g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" /><path d="M144 176a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m88-48A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88m-92-32a12 12 0 1 0-12-12a12 12 0 0 0 12 12" /></g>,
};
export default function ToastKinds({
messages = { ok: "Saved", warn: "Storage almost full", bad: "Sending failed", info: "A new version is out" },
picks = { ok: "Success", warn: "Warning", bad: "Error", info: "Info" },
groupLabel = "Pick a toast kind",
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
}: ToastKindsProps) {
const [demo, setDemo] = useState(true);
const [kind, setKind] = useState<ToastKind>("ok");
const [pop, setPop] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
function pick(k: ToastKind) {
setDemo(false);
setKind(k);
setPop((v) => v + 1); // key 를 바꿔 같은 종류를 다시 눌러도 팝이 한 번 더 돈다
}
return (
<div className={`${styles.tk} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.box}>
<div key={pop} className={`${styles.card} ${demo ? "" : styles.isPop}`} data-kind={kind} role="status" aria-live="polite">
<span className={styles.bar} data-kind={kind} aria-hidden="true" />
{ORDER.map((k) => (
<span key={k} className={styles.face} data-kind={k} data-on={!demo && k === kind ? "1" : "0"}>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="26" height="26">{ICON[k]}</svg>
</span>
<span className={styles.msg}>{messages[k]}</span>
</span>
))}
</div>
</div>
<div className={styles.row} role="group" aria-label={groupLabel}>
{ORDER.map((k) => (
<button key={k} className={styles.pick} type="button" data-pick={k} aria-pressed={!demo && k === kind} onClick={() => pick(k)}>
{picks[k]}
</button>
))}
</div>
</div>
);
}02 · 種類ごとのアイコンと色 — 計測 累積面積 10.784% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
負の animation-delay を変えるだけで、同じキーフレーム1つで4つの顔を回せます。
.tk.is-demo .tk__face {
animation-name: tk-face;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tk.is-demo .tk__face[data-kind="ok"] { animation-delay: 0s; }
.tk.is-demo .tk__face[data-kind="warn"] { animation-delay: -1.5s; }
.tk.is-demo .tk__face[data-kind="bad"] { animation-delay: -1s; }
.tk.is-demo .tk__face[data-kind="info"] { animation-delay: -0.5s; }
@keyframes tk-face {
0% { opacity: 1; }
25% { opacity: 0; }
100% { opacity: 0; }
}
03残り時間バーと一時停止
どんな動きか
下のバーは scaleX で縮み、軸を左に置いているので右端から減っていきます。マウスを乗せるかキーボードの焦点が入ると、バーには animation-play-state: paused をかけ、閉じるタイマーはいったん消します。離れたときに、経過分を引いた残り時間でタイマーを掛け直します。

<main class="stage">
<div class="pb is-demo">
<div class="pb__box">
<div class="pb__toast is-on" id="pb-toast" role="region" aria-label="自動で閉じる通知" tabindex="0">
<div class="pb__row">
<span class="pb__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
<span class="pb__txt">
<span class="pb__msg">レポートを書き出しました</span>
<span class="pb__sub">まもなく自動で閉じます</span>
</span>
<span class="pb__hold" id="pb-hold" aria-hidden="true">
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" opacity=".2"/><path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40ZM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40Z"/></g></svg>
<span class="pb__holdtxt">一時停止</span>
</span>
<button class="pb__x" type="button" id="pb-x" aria-label="通知を閉じる">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path 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" opacity=".2"/><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>
</button>
</div>
<span class="pb__track" aria-hidden="true"><span class="pb__fill" id="pb-fill"></span></span>
</div>
</div>
<button class="pb__go" type="button" id="pb-go">お知らせを出す</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.pb {
box-sizing: border-box;
width: min(404px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.pb__box {
position: relative;
overflow: hidden;
height: 76px;
}
.pb__toast {
position: absolute;
left: 0;
right: 0;
top: 4px;
box-sizing: border-box;
overflow: hidden;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
transform-origin: top center; // 규칙8: 순수 이동, origin 무의미
opacity: 0;
transform: translateY(-24px);
transition: opacity $duration $easing, transform $duration $easing;
}
.pb__toast.is-on { opacity: 1; transform: translateY(0); }
.pb__toast:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.pb__row {
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-3 $sp-4;
}
.pb__ic { display: inline-flex; flex: 0 0 auto; color: $color; }
.pb__ic svg { display: block; }
.pb__txt { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; }
.pb__msg {
font-size: 13px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pb__sub {
font-size: 11px;
line-height: 16px;
font-weight: 500;
color: rgba($stage-ink, .72);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// 멈춤 표시 — 사람이 올려 놓고 있는 동안만 나온다
.pb__hold {
display: inline-flex;
align-items: center;
gap: $sp-1;
flex: 0 0 auto;
padding: 0 $sp-2;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
opacity: 0;
}
.pb__hold svg { display: block; }
.pb__holdtxt { font-size: 10px; line-height: 18px; font-weight: 700; }
.pb__toast.is-hold .pb__hold { opacity: 1; }
.pb__x {
display: inline-flex;
flex: 0 0 auto;
margin: 0;
padding: $sp-1;
border: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
color: $subject-ink;
cursor: pointer;
}
.pb__x svg { display: block; }
.pb__x:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// 남은 시간 바 — scaleX 는 레이아웃을 다시 계산하지 않는다. 왼쪽을 축으로 잡아야 오른쪽부터 줄어든다
.pb__track {
display: block;
height: 4px;
background: rgba(23, 20, 26, .1);
}
.pb__fill {
display: block;
height: 100%;
background: $color;
transform-origin: left center; // 규칙8: 좌변 축으로 줄어듦
transform: scaleX(1);
}
.pb__toast.is-on .pb__fill {
animation-name: pb-drain;
animation-duration: 4200ms;
animation-timing-function: linear;
animation-iteration-count: 1;
animation-fill-mode: forwards;
}
.pb__toast.is-hold .pb__fill { animation-play-state: paused; }
@keyframes pb-drain {
0% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
.pb__go {
box-sizing: border-box;
align-self: center;
margin: 0;
padding: $sp-2 $sp-4;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-size: 12px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.pb__go:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// ── 자동 루프: 바가 줄다가 가운데서 한 번 멈추고(호버 흉내) 마저 줄어들면 카드가 한 번 내려앉는다 ──
.pb.is-demo .pb__toast {
opacity: 1;
transition: none;
animation-name: pb-card;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 루프에서는 카드를 지우지 않는다 — 사라지는 순간이 곧 "가장 크게 변한 장" 이라 poster 가 빈 화면이 된다.
// 대신 바가 다 닳으면 카드가 한 번 내려앉았다 돌아오고 다음 알림이 들어온 것처럼 바가 다시 찬다
@keyframes pb-card {
0%, 74% { transform: scale(1); }
79% { transform: scale(.955); }
86% { transform: scale(1); }
90%, 100% { transform: scale(1); }
}
.pb.is-demo .pb__fill {
animation-name: pb-loop;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pb-loop {
0%, 6% { transform: scaleX(1); }
34% { transform: scaleX(.62); }
51% { transform: scaleX(.62); }
74% { transform: scaleX(0); }
86% { transform: scaleX(0); }
90%, 100% { transform: scaleX(1); }
}
// 멈춤 칩은 샘플 프레임 사이에서 켜고 꺼 반투명 유령이 남지 않게 한다
.pb.is-demo .pb__hold {
animation-name: pb-hold;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pb-hold {
0%, 34% { opacity: 0; }
35%, 51% { opacity: 1; }
52%, 100% { opacity: 0; }
}
@media (max-width: 340px) {
.pb { gap: $sp-2; }
.pb__box { height: 64px; }
.pb__row { gap: $sp-2; padding: $sp-2 $sp-3; }
.pb__msg { font-size: 12px; line-height: 16px; }
.pb__sub { font-size: 10px; line-height: 14px; }
.pb__go { padding: $sp-1 $sp-3; font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.pb.is-demo .pb__toast,
.pb.is-demo .pb__fill,
.pb.is-demo .pb__hold,
.pb__toast.is-on .pb__fill { animation: none !important; }
.pb.is-demo .pb__toast { opacity: 1; transform: none; }
.pb__toast { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pb {
box-sizing: border-box;
width: min(404px, 88vw);
display: flex;
flex-direction: column;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.pb__box {
position: relative;
overflow: hidden;
height: 76px;
}
.pb__toast {
position: absolute;
left: 0;
right: 0;
top: 4px;
box-sizing: border-box;
overflow: hidden;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: top center;
opacity: 0;
transform: translateY(-24px);
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pb__toast.is-on {
opacity: 1;
transform: translateY(0);
}
.pb__toast:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.pb__row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
}
.pb__ic {
display: inline-flex;
flex: 0 0 auto;
color: #2f6df6;
}
.pb__ic svg {
display: block;
}
.pb__txt {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
}
.pb__msg {
font-size: 13px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pb__sub {
font-size: 11px;
line-height: 16px;
font-weight: 500;
color: rgba(23, 20, 26, 0.72);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pb__hold {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
padding: 0 8px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
opacity: 0;
}
.pb__hold svg {
display: block;
}
.pb__holdtxt {
font-size: 10px;
line-height: 18px;
font-weight: 700;
}
.pb__toast.is-hold .pb__hold {
opacity: 1;
}
.pb__x {
display: inline-flex;
flex: 0 0 auto;
margin: 0;
padding: 4px;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
cursor: pointer;
}
.pb__x svg {
display: block;
}
.pb__x:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.pb__track {
display: block;
height: 4px;
background: rgba(23, 20, 26, 0.1);
}
.pb__fill {
display: block;
height: 100%;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(1);
}
.pb__toast.is-on .pb__fill {
animation-name: pb-drain;
animation-duration: 4200ms;
animation-timing-function: linear;
animation-iteration-count: 1;
animation-fill-mode: forwards;
}
.pb__toast.is-hold .pb__fill {
animation-play-state: paused;
}
@keyframes pb-drain {
0% {
transform: scaleX(1);
}
100% {
transform: scaleX(0);
}
}
.pb__go {
box-sizing: border-box;
align-self: center;
margin: 0;
padding: 8px 16px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-size: 12px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.pb__go:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.pb.is-demo .pb__toast {
opacity: 1;
transition: none;
animation-name: pb-card;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes pb-card {
0%, 74% {
transform: scale(1);
}
79% {
transform: scale(0.955);
}
86% {
transform: scale(1);
}
90%, 100% {
transform: scale(1);
}
}
.pb.is-demo .pb__fill {
animation-name: pb-loop;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pb-loop {
0%, 6% {
transform: scaleX(1);
}
34% {
transform: scaleX(0.62);
}
51% {
transform: scaleX(0.62);
}
74% {
transform: scaleX(0);
}
86% {
transform: scaleX(0);
}
90%, 100% {
transform: scaleX(1);
}
}
.pb.is-demo .pb__hold {
animation-name: pb-hold;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pb-hold {
0%, 34% {
opacity: 0;
}
35%, 51% {
opacity: 1;
}
52%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.pb {
gap: 8px;
}
.pb__box {
height: 64px;
}
.pb__row {
gap: 8px;
padding: 8px 12px;
}
.pb__msg {
font-size: 12px;
line-height: 16px;
}
.pb__sub {
font-size: 10px;
line-height: 14px;
}
.pb__go {
padding: 4px 12px;
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.pb.is-demo .pb__toast,
.pb.is-demo .pb__fill,
.pb.is-demo .pb__hold,
.pb__toast.is-on .pb__fill {
animation: none !important;
}
.pb.is-demo .pb__toast {
opacity: 1;
transform: none;
}
.pb__toast {
transition: none;
}
}// 실제 동작: 진행 바는 CSS 애니메이션이라 animation-play-state 로 멈추고, 닫힘 타이머는 남은 시간을 계산해 다시 건다
var LIFE = 4200;
var root = document.querySelector('.pb');
var toast = document.getElementById('pb-toast');
var timer = 0, rest = LIFE, start = 0;
function show() {
root.classList.remove('is-demo');
toast.classList.remove('is-on', 'is-hold');
void toast.offsetWidth; // 같은 알림을 다시 띄워도 바가 처음부터 줄게 리플로를 강제한다
toast.classList.add('is-on');
rest = LIFE; start = Date.now();
clearTimeout(timer); timer = setTimeout(hide, LIFE);
}
function hide() { clearTimeout(timer); toast.classList.remove('is-on', 'is-hold'); }
function hold() {
if (!toast.classList.contains('is-on') || toast.classList.contains('is-hold')) return;
clearTimeout(timer); rest -= Date.now() - start; toast.classList.add('is-hold');
}
function go() {
if (!toast.classList.contains('is-on') || !toast.classList.contains('is-hold')) return;
start = Date.now(); toast.classList.remove('is-hold'); timer = setTimeout(hide, Math.max(rest, 0));
}
toast.addEventListener('mouseenter', hold);
toast.addEventListener('mouseleave', go);
toast.addEventListener('focusin', hold);
toast.addEventListener('focusout', go);
document.getElementById('pb-x').addEventListener('click', hide);
document.getElementById('pb-go').addEventListener('click', show);import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ProgressToast.module.scss";
export type ProgressToastProps = {
message?: string;
sub?: string;
holdLabel?: string;
closeLabel?: string;
openLabel?: string;
regionLabel?: string;
life?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--life"?: string };
export default function ProgressToast({
message = "Report exported",
sub = "This closes on its own in a moment",
holdLabel = "Paused",
closeLabel = "Close notification",
openLabel = "Show the notice",
regionLabel = "Self-closing notification",
life = 4200,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ProgressToastProps) {
const [demo, setDemo] = useState(true);
const [on, setOn] = useState(false);
const [hold, setHold] = useState(false);
const [run, setRun] = useState(0);
const timer = useRef<number[]>([0]);
const rest = useRef<number[]>([life, 0]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, "--life": `${life}ms` };
useEffect(() => () => { clearTimeout((timer.current ?? [0])[0]); }, []);
function later(ms: number) {
const t = timer.current ?? [0];
clearTimeout(t[0]);
t[0] = setTimeout(() => { setOn(false); setHold(false); }, Math.max(ms, 0)) as unknown as number;
}
function show() {
setDemo(false);
setOn(true);
setHold(false);
setRun((v) => v + 1); // key 를 바꿔 바가 처음부터 다시 줄게 한다
const r = rest.current ?? [life, 0];
r[0] = life; r[1] = Date.now();
later(life);
}
function pause() {
if (!on || hold) return;
const r = rest.current ?? [life, 0];
clearTimeout((timer.current ?? [0])[0]);
r[0] = r[0] - (Date.now() - r[1]);
setHold(true);
}
function resume() {
if (!on || !hold) return;
const r = rest.current ?? [life, 0];
r[1] = Date.now();
setHold(false);
later(r[0]);
}
return (
<div className={`${styles.pb} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.box}>
<div
className={`${styles.toast} ${on ? styles.isOn : ""} ${hold ? styles.isHold : ""}`}
role="region"
aria-label={regionLabel}
tabIndex={0}
onMouseEnter={pause}
onMouseLeave={resume}
onFocus={pause}
onBlur={resume}
>
<div className={styles.row}>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" /><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" /></g></svg>
</span>
<span className={styles.txt}>
<span className={styles.msg}>{message}</span>
<span className={styles.sub}>{sub}</span>
</span>
<span className={styles.hold} aria-hidden="true">
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" opacity=".2" /><path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40ZM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40Z" /></g></svg>
<span className={styles.holdtxt}>{holdLabel}</span>
</span>
<button className={styles.x} type="button" aria-label={closeLabel} onClick={() => { setOn(false); setHold(false); clearTimeout((timer.current ?? [0])[0]); }}>
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path 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" opacity=".2" /><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>
</button>
</div>
<span className={styles.track} aria-hidden="true"><span key={run} className={styles.fill} /></span>
</div>
</div>
<button className={styles.go} type="button" onClick={show}>{openLabel}</button>
</div>
);
}03 · 残り時間バーと一時停止 — 計測 累積面積 5.482% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
どちらか片方だけ止めると、バーは止まったまま通知だけが閉じてしまいます。
.pb__fill {
display: block;
height: 100%;
background: $color;
transform-origin: left center;
transform: scaleX(1);
}
.pb__toast.is-on .pb__fill {
animation-name: pb-drain;
animation-duration: 4200ms;
animation-timing-function: linear;
animation-iteration-count: 1;
animation-fill-mode: forwards;
}
.pb__toast.is-hold .pb__fill { animation-play-state: paused; }
@keyframes pb-drain {
0% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
04スワイプで飛ばす
何が見えるか
押したまま横に引くとカードが指にそのまま追従し、離したときの移動量が 72px を超えていればその向きへ 420px 飛んで閉じます。足りなければ移動量を 0 に戻して元の位置へ弾んで戻ります。

<main class="stage">
<div class="sw is-demo">
<div class="sw__box">
<article class="sw__under" aria-hidden="true">
<span class="sw__ic"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg></span>
<span class="sw__msg">未読のお知らせがまだあります</span>
</article>
<article class="sw__card" id="sw-card" tabindex="0" role="region" aria-label="スワイプで閉じる通知">
<span class="sw__ic"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M144 176a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m88-48A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88m-92-32a12 12 0 1 0-12-12a12 12 0 0 0 12 12"/></g></svg></span>
<span class="sw__msg">新しいメッセージが届きました</span>
</article>
</div>
<p class="sw__hint">
<span class="sw__hic" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M208 80v96H48V80Z" opacity=".2"/><path d="m213.66 181.66l-32 32a8 8 0 0 1-11.32-11.32L188.69 184H48a8 8 0 0 1 0-16h140.69l-18.35-18.34a8 8 0 0 1 11.32-11.32l32 32a8 8 0 0 1 0 11.32m-139.32-64a8 8 0 0 0 11.32-11.32L67.31 88H208a8 8 0 0 0 0-16H67.31l18.35-18.34a8 8 0 0 0-11.32-11.32l-32 32a8 8 0 0 0 0 11.32Z"/></g></svg></span>
左右にドラッグすると閉じます
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.sw {
box-sizing: border-box;
width: min(396px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
// 날아간 카드는 이 상자 밖으로 나가므로 여기서 잘려 화면을 넓히지 않는다
.sw__box {
position: relative;
overflow: hidden;
height: 96px;
}
.sw__card,
.sw__under {
position: absolute;
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-3;
margin: 0;
padding: $sp-3 $sp-4;
border-radius: $r-card;
}
.sw__under {
left: 16px;
right: 16px;
top: 26px;
background: $subject-cream;
border: 2px solid rgba(23, 20, 26, .12);
color: rgba($stage-ink, .72);
}
.sw__card {
left: 0;
right: 0;
top: 4px;
--dx: 0px;
background: $subject-ink;
color: $subject-cream;
box-shadow: $shadow-raised;
cursor: grab;
touch-action: pan-y;
transform: translateX(var(--dx));
transition: transform $duration $easing;
}
.sw__card.is-drag { cursor: grabbing; transition: none; }
.sw__card.is-out { transition: transform $dur-enter $ease-out; }
.sw__card:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.sw__ic { display: inline-flex; flex: 0 0 auto; color: $color; }
.sw__ic svg { display: block; }
.sw__under .sw__ic { color: rgba(23, 20, 26, .34); }
.sw__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 13px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sw__hint {
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
margin: 0;
font-size: 11px;
line-height: 16px;
font-weight: 700;
color: rgba($stage-ink, .72);
}
.sw__hic { display: inline-flex; }
.sw__hic svg { display: block; }
// ── 자동 루프: 살짝 밀었다 되돌아오고(문턱 미만), 한 번 더 세게 밀어 날린 뒤 제자리에서 다시 뜬다 ──
// 카드가 빠진 사이에도 아래 카드가 남아 있어 어느 프레임도 빈 화면이 아니다
.sw.is-demo .sw__card {
transition: none;
animation-name: sw-loop;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes sw-loop {
0%, 10% { opacity: 1; transform: translateX(0) rotate(0deg); }
22% { opacity: 1; transform: translateX(48px) rotate(1.4deg); }
34% { opacity: 1; transform: translateX(0) rotate(0deg); }
46% { opacity: 1; transform: translateX(-78px) rotate(-2.2deg); }
62% { opacity: 1; transform: translateX(-420px) rotate(-6deg); }
70.5% { opacity: 0; transform: translateX(-420px) rotate(-6deg); }
71.5% { opacity: 0; transform: translateX(0) rotate(0deg); }
73% { opacity: 1; transform: translateX(0) rotate(0deg); }
90%, 100% { opacity: 1; transform: translateX(0) rotate(0deg); }
}
@media (max-width: 340px) {
.sw { gap: $sp-2; }
.sw__box { height: 84px; }
.sw__card,
.sw__under { padding: $sp-2 $sp-3; gap: $sp-2; }
.sw__under { top: 22px; }
.sw__msg { font-size: 11px; line-height: 16px; }
.sw__hint { font-size: 10px; line-height: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.sw.is-demo .sw__card { animation: none !important; opacity: 1; transform: translateX(0); }
.sw__card { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.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 {
box-sizing: border-box;
width: min(396px, 88vw);
display: flex;
flex-direction: column;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.sw__box {
position: relative;
overflow: hidden;
height: 96px;
}
.sw__card,
.sw__under {
position: absolute;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 12px;
margin: 0;
padding: 12px 16px;
border-radius: 16px;
}
.sw__under {
left: 16px;
right: 16px;
top: 26px;
background: #fff7e6;
border: 2px solid rgba(23, 20, 26, 0.12);
color: rgba(23, 20, 26, 0.72);
}
.sw__card {
left: 0;
right: 0;
top: 4px;
--dx: 0px;
background: #17141a;
color: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: pan-y;
transform: translateX(var(--dx));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sw__card.is-drag {
cursor: grabbing;
transition: none;
}
.sw__card.is-out {
transition: transform 600ms ease-out;
}
.sw__card:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.sw__ic {
display: inline-flex;
flex: 0 0 auto;
color: #2f6df6;
}
.sw__ic svg {
display: block;
}
.sw__under .sw__ic {
color: rgba(23, 20, 26, 0.34);
}
.sw__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 13px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sw__hint {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: 0;
font-size: 11px;
line-height: 16px;
font-weight: 700;
color: rgba(23, 20, 26, 0.72);
}
.sw__hic {
display: inline-flex;
}
.sw__hic svg {
display: block;
}
.sw.is-demo .sw__card {
transition: none;
animation-name: sw-loop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes sw-loop {
0%, 10% {
opacity: 1;
transform: translateX(0) rotate(0deg);
}
22% {
opacity: 1;
transform: translateX(48px) rotate(1.4deg);
}
34% {
opacity: 1;
transform: translateX(0) rotate(0deg);
}
46% {
opacity: 1;
transform: translateX(-78px) rotate(-2.2deg);
}
62% {
opacity: 1;
transform: translateX(-420px) rotate(-6deg);
}
70.5% {
opacity: 0;
transform: translateX(-420px) rotate(-6deg);
}
71.5% {
opacity: 0;
transform: translateX(0) rotate(0deg);
}
73% {
opacity: 1;
transform: translateX(0) rotate(0deg);
}
90%, 100% {
opacity: 1;
transform: translateX(0) rotate(0deg);
}
}
@media (max-width: 340px) {
.sw {
gap: 8px;
}
.sw__box {
height: 84px;
}
.sw__card,
.sw__under {
padding: 8px 12px;
gap: 8px;
}
.sw__under {
top: 22px;
}
.sw__msg {
font-size: 11px;
line-height: 16px;
}
.sw__hint {
font-size: 10px;
line-height: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.sw.is-demo .sw__card {
animation: none !important;
opacity: 1;
transform: translateX(0);
}
.sw__card {
transition: none;
}
}// 실제 동작: 포인터를 잡아 두고(--dx) 끌다가 72px 을 넘겨 놓으면 그 방향으로 날린다
var OUT = 72;
var root = document.querySelector('.sw');
var card = document.getElementById('sw-card');
var x0 = 0, dx = 0, down = false;
function set(v) { card.style.setProperty('--dx', v + 'px'); }
card.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
down = true; x0 = e.clientX; dx = 0;
card.setPointerCapture(e.pointerId); // 손가락이 카드 밖으로 나가도 move 가 계속 온다
card.classList.add('is-drag');
});
card.addEventListener('pointermove', function (e) {
if (!down) return;
dx = e.clientX - x0;
set(dx);
});
card.addEventListener('pointerup', function () {
if (!down) return;
down = false;
card.classList.remove('is-drag');
if (Math.abs(dx) > OUT) fling(dx < 0 ? -1 : 1); else set(0);
});
card.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
root.classList.remove('is-demo');
e.preventDefault();
fling(e.key === 'ArrowLeft' ? -1 : 1);
});
function fling(dir) {
card.classList.add('is-out');
set(dir * 420);
setTimeout(function () { // 잠시 뒤 다음 알림이 제자리에서 다시 뜬다
card.classList.remove('is-out');
set(0);
}, 900);
}import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SwipeToast.module.scss";
export type SwipeToastProps = {
message?: string;
under?: string;
hint?: string;
regionLabel?: string;
threshold?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--dx"?: string; "--duration"?: string; "--easing"?: string; "--color"?: string };
// React 이벤트 타입은 'react' 에서 import 하지 않는다 — 필요한 모양만 여기서 선언한다
type PointerEvt = { clientX: number; pointerId: number; currentTarget: { setPointerCapture: (id: number) => void } };
type KeyEvt = { key: string; preventDefault: () => void };
export default function SwipeToast({
message = "You have a new message",
under = "More unread updates behind this",
hint = "Drag sideways to dismiss",
regionLabel = "Swipe-to-dismiss notification",
threshold = 72,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: SwipeToastProps) {
const [demo, setDemo] = useState(true);
const [dx, setDx] = useState(0);
const [drag, setDrag] = useState(false);
const [out, setOut] = useState(false);
const from = useRef<number[]>([0]);
const back = useRef<number[]>([0]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cardVars: CSSProperties & { "--dx"?: string } = { "--dx": `${dx}px` };
function fling(dir: number) {
setOut(true);
setDx(dir * 420);
const b = back.current ?? [0];
clearTimeout(b[0]);
b[0] = setTimeout(() => { setOut(false); setDx(0); }, 900) as unknown as number;
}
return (
<div className={`${styles.sw} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.box}>
<article className={styles.under} aria-hidden="true">
<span className={styles.ic}>
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2" /><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z" /></g></svg>
</span>
<span className={styles.msg}>{under}</span>
</article>
<article
className={`${styles.card} ${drag ? styles.isDrag : ""} ${out ? styles.isOut : ""}`}
style={cardVars}
tabIndex={0}
role="region"
aria-label={regionLabel}
onPointerDown={(e: PointerEvt) => {
setDemo(false);
setDrag(true);
const f = from.current ?? [0];
f[0] = e.clientX;
e.currentTarget.setPointerCapture(e.pointerId); // 손가락이 카드를 벗어나도 move 가 계속 온다
}}
onPointerMove={(e: PointerEvt) => {
if (!drag) return;
setDx(e.clientX - (from.current ?? [0])[0]);
}}
onPointerUp={() => {
if (!drag) return;
setDrag(false);
if (Math.abs(dx) > threshold) fling(dx < 0 ? -1 : 1); else setDx(0);
}}
onKeyDown={(e: KeyEvt) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
setDemo(false);
e.preventDefault();
fling(e.key === "ArrowLeft" ? -1 : 1);
}}
>
<span className={styles.ic}>
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" /><path d="M144 176a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m88-48A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88m-92-32a12 12 0 1 0-12-12a12 12 0 0 0 12 12" /></g></svg>
</span>
<span className={styles.msg}>{message}</span>
</article>
</div>
<p className={styles.hint}>
<span className={styles.hic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M208 80v96H48V80Z" opacity=".2" /><path d="m213.66 181.66l-32 32a8 8 0 0 1-11.32-11.32L188.69 184H48a8 8 0 0 1 0-16h140.69l-18.35-18.34a8 8 0 0 1 11.32-11.32l32 32a8 8 0 0 1 0 11.32m-139.32-64a8 8 0 0 0 11.32-11.32L67.31 88H208a8 8 0 0 0 0-16H67.31l18.35-18.34a8 8 0 0 0-11.32-11.32l-32 32a8 8 0 0 0 0 11.32Z" /></g></svg>
</span>
{hint}
</p>
</div>
);
}04 · スワイプで飛ばす — 計測 累積面積 19.567% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
押した瞬間に setPointerCapture を呼んでおかないと、指がカードの外へ出た時点で移動イベントが途切れます。
card.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
down = true; x0 = e.clientX; dx = 0;
card.setPointerCapture(e.pointerId); // 指がカードの外へ出ても move が届き続ける
card.classList.add('is-drag');
});
card.addEventListener('pointermove', function (e) {
if (!down) return;
dx = e.clientX - x0;
set(dx);
});
card.addEventListener('pointerup', function () {
if (!down) return;
down = false;
card.classList.remove('is-drag');
if (Math.abs(dx) > OUT) fling(dx < 0 ? -1 : 1); else set(0);
});
05本当に元に戻すボタン
画面で起きること
消した行はすぐに取り除かず、grid-template-rows を 1fr から 0fr へ畳んで隠すだけにしています。元に戻すボタンを押したら予約してある削除を clearTimeout で取り消し、畳みを解けばよいので、消した中身をどこかに控えておく必要がありません。

<main class="stage">
<div class="un is-demo">
<ul class="un__list" id="un-list" aria-label="ファイル一覧">
<li class="un__row"><span class="un__in">
<span class="un__name">2月請求書.pdf</span>
<button class="un__del" type="button" aria-label="この行を削除">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>
</button>
</span></li>
<li class="un__row"><span class="un__in">
<span class="un__name">会議メモ.md</span>
<button class="un__del" type="button" aria-label="この行を削除">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>
</button>
</span></li>
<li class="un__row"><span class="un__in">
<span class="un__name">ロゴ最終.png</span>
<button class="un__del" type="button" aria-label="この行を削除">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>
</button>
</span></li>
</ul>
<div class="un__slot">
<div class="un__toast" id="un-toast">
<span class="un__msg" id="un-msg" role="status" aria-live="polite">項目を削除しました</span>
<button class="un__undo" type="button" id="un-undo">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg>
元に戻す
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-ink);
.un {
box-sizing: border-box;
width: min(396px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
}
.un__list {
box-sizing: border-box;
height: 108px;
margin: 0;
padding: 0;
list-style: none;
}
// 줄 접기 — grid-template-rows 1fr → 0fr 은 높이를 안다는 뜻이라 접힌 뒤 아래 줄이 자연스럽게 올라온다
.un__row {
display: grid;
grid-template-rows: 1fr;
margin: 0;
padding: 0;
transition: grid-template-rows $duration $easing, opacity $dur-quick linear;
}
.un__row.is-gone { grid-template-rows: 0fr; opacity: 0; }
.un__in {
display: flex;
align-items: center;
gap: $sp-2;
overflow: hidden;
min-height: 0;
margin: 0 0 $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .07);
}
.un__name {
flex: 1 1 auto;
min-width: 0;
font-size: 12px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.un__del {
display: inline-flex;
flex: 0 0 auto;
margin: 0;
padding: $sp-1;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .1);
color: $subject-cream;
cursor: pointer;
}
.un__del svg { display: block; }
.un__del:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 토스트 자리 — 높이를 미리 잡아 두어야 토스트가 뜰 때 위 목록이 밀리지 않는다
.un__slot {
position: relative;
overflow: hidden;
height: 44px;
}
.un__toast {
position: absolute;
left: 0;
right: 0;
bottom: 4px;
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-2 $sp-2 $sp-2 $sp-4;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
box-shadow: $shadow-press;
transform: translateY(48px);
transition: transform $duration $easing;
}
.un__toast.is-on { transform: translateY(0); }
.un__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 12px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.un__undo {
display: inline-flex;
align-items: center;
gap: $sp-1;
flex: 0 0 auto;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
color: $color;
font-family: inherit;
font-size: 11px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.un__undo svg { display: block; }
.un__undo:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// ── 자동 루프: 가운데 줄이 접히고 → 토스트가 올라오고 → 되돌리기가 눌려 → 줄이 도로 펴진다 ──
.un.is-demo .un__row { transition: none; }
.un.is-demo .un__row:nth-child(2) {
animation-name: un-row;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes un-row {
0%, 18% { grid-template-rows: 1fr; opacity: 1; }
30% { grid-template-rows: 0fr; opacity: 0; }
62% { grid-template-rows: 0fr; opacity: 0; }
76% { grid-template-rows: 1fr; opacity: 1; }
90%, 100% { grid-template-rows: 1fr; opacity: 1; }
}
.un.is-demo .un__toast {
transition: none;
animation-name: un-toast;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes un-toast {
0%, 20% { transform: translateY(48px); }
32% { transform: translateY(0); }
66% { transform: translateY(0); }
80% { transform: translateY(48px); }
90%, 100% { transform: translateY(48px); }
}
// 되돌리기가 눌리는 순간 — 줄이 다시 펴지기 직전이다
.un.is-demo .un__undo {
animation-name: un-press;
animation-duration: $dur-loop;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
@keyframes un-press {
0%, 56% { transform: scale(1); }
62% { transform: scale(.84); }
70% { transform: scale(1); }
90%, 100% { transform: scale(1); }
}
@media (max-width: 340px) {
.un__list { height: 90px; }
.un__in { margin: 0 0 $sp-1; padding: $sp-1 $sp-2; }
.un__name { font-size: 11px; line-height: 16px; }
.un__slot { height: 36px; }
.un__toast { gap: $sp-2; padding: $sp-1 $sp-1 $sp-1 $sp-3; }
.un__msg { font-size: 11px; line-height: 18px; }
.un__undo { padding: 0 $sp-2; font-size: 10px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.un.is-demo .un__row:nth-child(2),
.un.is-demo .un__toast,
.un.is-demo .un__undo { animation: none !important; }
.un.is-demo .un__toast { transform: translateY(0); }
.un__row,
.un__toast { transition: none; }
}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);
}
.un {
box-sizing: border-box;
width: min(396px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.un__list {
box-sizing: border-box;
height: 108px;
margin: 0;
padding: 0;
list-style: none;
}
.un__row {
display: grid;
grid-template-rows: 1fr;
margin: 0;
padding: 0;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms linear;
}
.un__row.is-gone {
grid-template-rows: 0fr;
opacity: 0;
}
.un__in {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
min-height: 0;
margin: 0 0 8px;
padding: 8px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.07);
}
.un__name {
flex: 1 1 auto;
min-width: 0;
font-size: 12px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.un__del {
display: inline-flex;
flex: 0 0 auto;
margin: 0;
padding: 4px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
color: #fff7e6;
cursor: pointer;
}
.un__del svg {
display: block;
}
.un__del:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.un__slot {
position: relative;
overflow: hidden;
height: 44px;
}
.un__toast {
position: absolute;
left: 0;
right: 0;
bottom: 4px;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 8px 8px 16px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateY(48px);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.un__toast.is-on {
transform: translateY(0);
}
.un__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 12px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.un__undo {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #4cd4a6;
font-family: inherit;
font-size: 11px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.un__undo svg {
display: block;
}
.un__undo:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.un.is-demo .un__row {
transition: none;
}
.un.is-demo .un__row:nth-child(2) {
animation-name: un-row;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes un-row {
0%, 18% {
grid-template-rows: 1fr;
opacity: 1;
}
30% {
grid-template-rows: 0fr;
opacity: 0;
}
62% {
grid-template-rows: 0fr;
opacity: 0;
}
76% {
grid-template-rows: 1fr;
opacity: 1;
}
90%, 100% {
grid-template-rows: 1fr;
opacity: 1;
}
}
.un.is-demo .un__toast {
transition: none;
animation-name: un-toast;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes un-toast {
0%, 20% {
transform: translateY(48px);
}
32% {
transform: translateY(0);
}
66% {
transform: translateY(0);
}
80% {
transform: translateY(48px);
}
90%, 100% {
transform: translateY(48px);
}
}
.un.is-demo .un__undo {
animation-name: un-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes un-press {
0%, 56% {
transform: scale(1);
}
62% {
transform: scale(0.84);
}
70% {
transform: scale(1);
}
90%, 100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.un__list {
height: 90px;
}
.un__in {
margin: 0 0 4px;
padding: 4px 8px;
}
.un__name {
font-size: 11px;
line-height: 16px;
}
.un__slot {
height: 36px;
}
.un__toast {
gap: 8px;
padding: 4px 4px 4px 12px;
}
.un__msg {
font-size: 11px;
line-height: 18px;
}
.un__undo {
padding: 0 8px;
font-size: 10px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.un.is-demo .un__row:nth-child(2),
.un.is-demo .un__toast,
.un.is-demo .un__undo {
animation: none !important;
}
.un.is-demo .un__toast {
transform: translateY(0);
}
.un__row,
.un__toast {
transition: none;
}
}// 실제 동작: 지운 줄은 割り込む 없애지 않고 접어만 둔다. 되돌리기를 누르면 예약된 삭제를 clearTimeout 으로 취소하고 다시 편다
var root = document.querySelector('.un');
var toast = document.getElementById('un-toast');
var timer = 0, pending = null;
function drop(row) {
root.classList.remove('is-demo');
if (pending) commit();
pending = row;
row.classList.add('is-gone');
toast.classList.add('is-on');
clearTimeout(timer);
timer = setTimeout(commit, 4000);
}
function commit() { // 元に戻す 없이 시간이 다 가면 그때 진짜로 지운다
clearTimeout(timer);
if (pending) pending.remove();
pending = null;
toast.classList.remove('is-on');
}
function undo() {
clearTimeout(timer);
if (pending) pending.classList.remove('is-gone');
pending = null;
toast.classList.remove('is-on');
}
[].forEach.call(document.querySelectorAll('.un__del'), function (b) {
b.addEventListener('click', function () { drop(b.closest('.un__row')); });
});
document.getElementById('un-undo').addEventListener('click', undo);import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./UndoToast.module.scss";
export type UndoToastProps = {
files?: string[];
listLabel?: string;
deleteLabel?: string;
message?: string;
undoLabel?: string;
grace?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function UndoToast({
files = ["february-invoice.pdf", "meeting-notes.md", "logo-final.png"],
listLabel = "File list",
deleteLabel = "Delete this row",
message = "Item deleted",
undoLabel = "Undo",
grace = 4000,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: UndoToastProps) {
const [demo, setDemo] = useState(true);
const [rows, setRows] = useState<string[]>(files);
const [gone, setGone] = useState<string>("");
const timer = useRef<number[]>([0]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => () => { clearTimeout((timer.current ?? [0])[0]); }, []);
function drop(name: string) {
setDemo(false);
setGone(name);
const t = timer.current ?? [0];
clearTimeout(t[0]);
// 시간이 다 가면 그때 진짜로 목록에서 뺀다 — 그 전까지는 접혀만 있다
t[0] = setTimeout(() => { setRows((v) => v.filter((n) => n !== name)); setGone(""); }, grace) as unknown as number;
}
function undo() {
clearTimeout((timer.current ?? [0])[0]); // 예약된 삭제를 취소하면 줄이 그대로 살아 있다
setGone("");
}
return (
<div className={`${styles.un} ${demo ? styles.isDemo : ""}`} style={vars}>
<ul className={styles.list} aria-label={listLabel}>
{rows.map((name, i) => (
<li key={name} className={`${styles.row} ${gone === name ? styles.isGone : ""}`} data-nth={i}>
<span className={styles.in}>
<span className={styles.name}>{name}</span>
<button className={styles.del} type="button" aria-label={deleteLabel} onClick={() => drop(name)}>
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2" /><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0" /></g></svg>
</button>
</span>
</li>
))}
</ul>
<div className={styles.slot}>
<div className={`${styles.toast} ${gone ? styles.isOn : ""}`}>
<span className={styles.msg} role="status" aria-live="polite">{gone ? message : ""}</span>
<button className={styles.undo} type="button" onClick={undo}>
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2" /><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128" /></g></svg>
{undoLabel}
</button>
</div>
</div>
</div>
);
}05 · 本当に元に戻すボタン — 計測 累積面積 26.956% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
トーストが出る場所は高さを先に確保してあるので、トーストが現れても上の一覧はずれません。
function drop(row) {
root.classList.remove('is-demo');
if (pending) commit();
pending = row;
row.classList.add('is-gone');
toast.classList.add('is-on');
clearTimeout(timer);
timer = setTimeout(commit, 4000);
}
function undo() {
clearTimeout(timer);
if (pending) pending.classList.remove('is-gone');
pending = null;
toast.classList.remove('is-on');
}
06読み込みから結果へ変わるトースト
どんな動きか
同じカードの中で回っているリングが、処理の終わった瞬間に成功か失敗の印へ切り替わります。リングは 1 秒で一周する rotate を無限に回し、切り替え点はコマとコマの間に置いた steps(1, end) のカットなので、2つのアイコンが半分ずつ見える区間がありません。

<main class="stage">
<div class="pm is-demo">
<div class="pm__card" id="pm-card" data-state="load" role="status" aria-live="polite" aria-busy="true">
<span class="pm__slot" aria-hidden="true">
<span class="pm__face" data-state="load">
<span class="pm__spin"><svg viewBox="0 0 256 256" width="30" height="30"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M232 128a104 104 0 0 1-208 0c0-41 23.81-78.36 60.66-95.27a8 8 0 0 1 6.68 14.54C60.15 61.59 40 93.27 40 128a88 88 0 0 0 176 0c0-34.73-20.15-66.41-51.34-80.73a8 8 0 0 1 6.68-14.54C208.19 49.64 232 87 232 128"/></g></svg></span>
</span>
<span class="pm__face" data-state="ok">
<svg viewBox="0 0 256 256" width="30" height="30"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>
</span>
<span class="pm__face" data-state="bad">
<svg viewBox="0 0 256 256" width="30" height="30"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M165.66 101.66L139.31 128l26.35 26.34a8 8 0 0 1-11.32 11.32L128 139.31l-26.34 26.35a8 8 0 0 1-11.32-11.32L116.69 128l-26.35-26.34a8 8 0 0 1 11.32-11.32L128 116.69l26.34-26.35a8 8 0 0 1 11.32 11.32M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>
</span>
</span>
<span class="pm__txt">
<span class="pm__words">
<span class="pm__word" data-state="load">アップロード中です</span>
<span class="pm__word" data-state="ok">アップロードが完了しました</span>
<span class="pm__word" data-state="bad">アップロードが途切れました</span>
</span>
<span class="pm__sub">写真12枚 · 34MB</span>
</span>
</div>
<button class="pm__go" type="button" id="pm-go">もう一度アップロード</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-sky;
@include stage($stage-yellow);
.pm {
box-sizing: border-box;
width: min(392px, 88vw);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.pm__card {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
gap: $sp-4;
padding: $sp-3 $sp-4;
border-radius: $r-card;
background: $subject-ink;
color: $subject-cream;
box-shadow: $shadow-raised;
}
// 아이콘 자리는 한 칸뿐이다 — 세 얼굴이 같은 자리에 겹쳐 있고 한 프레임에 갈린다
.pm__slot {
position: relative;
flex: 0 0 auto;
width: 30px;
height: 30px;
}
.pm__face {
position: absolute;
left: 0;
top: 0;
display: block;
opacity: 0;
}
.pm__face svg { display: block; }
.pm__face[data-state="load"] { color: $color; }
.pm__face[data-state="ok"] { color: $subject-mint; }
.pm__face[data-state="bad"] { color: $stage-orange; }
.pm__spin { display: block; }
.pm__txt { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; }
.pm__words {
position: relative;
display: block;
height: 20px;
}
.pm__word {
position: absolute;
left: 0;
right: 0;
top: 0;
display: block;
opacity: 0;
font-size: 14px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pm__sub {
font-size: 11px;
line-height: 16px;
font-weight: 500;
color: rgba(255, 247, 230, .56);
}
.pm__go {
box-sizing: border-box;
margin: 0;
padding: $sp-2 $sp-4;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: transparent;
color: $subject-ink;
font-family: inherit;
font-size: 12px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.pm__go:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// 사람이 누른 뒤: data-state 하나가 아이콘과 문구를 동시에 정한다
.pm:not(.is-demo) .pm__card[data-state="load"] .pm__face[data-state="load"],
.pm:not(.is-demo) .pm__card[data-state="ok"] .pm__face[data-state="ok"],
.pm:not(.is-demo) .pm__card[data-state="bad"] .pm__face[data-state="bad"] { opacity: 1; }
.pm:not(.is-demo) .pm__card[data-state="load"] .pm__word[data-state="load"],
.pm:not(.is-demo) .pm__card[data-state="ok"] .pm__word[data-state="ok"],
.pm:not(.is-demo) .pm__card[data-state="bad"] .pm__word[data-state="bad"] { opacity: 1; }
// 기다리는 고리는 늘 돈다 — 상태가 바뀌어도 같은 자리에서 결과 아이콘으로 갈릴 뿐이다
.pm__spin {
animation-name: pm-spin;
animation-duration: 1s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pm-spin {
0% { rotate: 0deg; }
100% { rotate: 360deg; }
}
// ── 자동 루프: 기다림 → 성공 → 기다림 → 실패. 갈리는 지점은 24장 샘플 사이에 두고 steps(1, end) 로 끊는다 ──
.pm.is-demo .pm__face,
.pm.is-demo .pm__word {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pm.is-demo .pm__face[data-state="load"],
.pm.is-demo .pm__word[data-state="load"] { animation-name: pm-load; }
.pm.is-demo .pm__face[data-state="ok"],
.pm.is-demo .pm__word[data-state="ok"] { animation-name: pm-ok; }
.pm.is-demo .pm__face[data-state="bad"],
.pm.is-demo .pm__word[data-state="bad"] { animation-name: pm-bad; }
@keyframes pm-load {
0% { opacity: 1; }
35% { opacity: 0; }
56% { opacity: 1; }
77% { opacity: 0; }
89% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes pm-ok {
0% { opacity: 0; }
35% { opacity: 1; }
56% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes pm-bad {
0% { opacity: 0; }
77% { opacity: 1; }
89% { opacity: 0; }
100% { opacity: 0; }
}
@media (max-width: 340px) {
.pm { gap: $sp-2; }
.pm__card { gap: $sp-3; padding: $sp-2 $sp-3; }
.pm__slot { width: 24px; height: 24px; }
.pm__face svg { width: 24px; height: 24px; }
.pm__words { height: 18px; }
.pm__word { font-size: 12px; line-height: 18px; }
.pm__sub { font-size: 10px; line-height: 14px; }
.pm__go { padding: $sp-1 $sp-3; font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.pm__spin,
.pm.is-demo .pm__face,
.pm.is-demo .pm__word { animation: none !important; }
.pm.is-demo .pm__face[data-state="load"] { opacity: 1; }
.pm.is-demo .pm__word[data-state="load"] { opacity: 1; }
}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);
}
.pm {
box-sizing: border-box;
width: min(392px, 88vw);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.pm__card {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
border-radius: 16px;
background: #17141a;
color: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.pm__slot {
position: relative;
flex: 0 0 auto;
width: 30px;
height: 30px;
}
.pm__face {
position: absolute;
left: 0;
top: 0;
display: block;
opacity: 0;
}
.pm__face svg {
display: block;
}
.pm__face[data-state=load] {
color: #6ec8ff;
}
.pm__face[data-state=ok] {
color: #4cd4a6;
}
.pm__face[data-state=bad] {
color: #ff4d1f;
}
.pm__spin {
display: block;
}
.pm__txt {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
}
.pm__words {
position: relative;
display: block;
height: 20px;
}
.pm__word {
position: absolute;
left: 0;
right: 0;
top: 0;
display: block;
opacity: 0;
font-size: 14px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pm__sub {
font-size: 11px;
line-height: 16px;
font-weight: 500;
color: rgba(255, 247, 230, 0.56);
}
.pm__go {
box-sizing: border-box;
margin: 0;
padding: 8px 16px;
border: 2px solid #17141a;
border-radius: 999px;
background: transparent;
color: #17141a;
font-family: inherit;
font-size: 12px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.pm__go:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.pm:not(.is-demo) .pm__card[data-state=load] .pm__face[data-state=load],
.pm:not(.is-demo) .pm__card[data-state=ok] .pm__face[data-state=ok],
.pm:not(.is-demo) .pm__card[data-state=bad] .pm__face[data-state=bad] {
opacity: 1;
}
.pm:not(.is-demo) .pm__card[data-state=load] .pm__word[data-state=load],
.pm:not(.is-demo) .pm__card[data-state=ok] .pm__word[data-state=ok],
.pm:not(.is-demo) .pm__card[data-state=bad] .pm__word[data-state=bad] {
opacity: 1;
}
.pm__spin {
animation-name: pm-spin;
animation-duration: 1s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pm-spin {
0% {
rotate: 0deg;
}
100% {
rotate: 360deg;
}
}
.pm.is-demo .pm__face,
.pm.is-demo .pm__word {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pm.is-demo .pm__face[data-state=load],
.pm.is-demo .pm__word[data-state=load] {
animation-name: pm-load;
}
.pm.is-demo .pm__face[data-state=ok],
.pm.is-demo .pm__word[data-state=ok] {
animation-name: pm-ok;
}
.pm.is-demo .pm__face[data-state=bad],
.pm.is-demo .pm__word[data-state=bad] {
animation-name: pm-bad;
}
@keyframes pm-load {
0% {
opacity: 1;
}
35% {
opacity: 0;
}
56% {
opacity: 1;
}
77% {
opacity: 0;
}
89% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes pm-ok {
0% {
opacity: 0;
}
35% {
opacity: 1;
}
56% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes pm-bad {
0% {
opacity: 0;
}
77% {
opacity: 1;
}
89% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.pm {
gap: 8px;
}
.pm__card {
gap: 12px;
padding: 8px 12px;
}
.pm__slot {
width: 24px;
height: 24px;
}
.pm__face svg {
width: 24px;
height: 24px;
}
.pm__words {
height: 18px;
}
.pm__word {
font-size: 12px;
line-height: 18px;
}
.pm__sub {
font-size: 10px;
line-height: 14px;
}
.pm__go {
padding: 4px 12px;
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.pm__spin,
.pm.is-demo .pm__face,
.pm.is-demo .pm__word {
animation: none !important;
}
.pm.is-demo .pm__face[data-state=load] {
opacity: 1;
}
.pm.is-demo .pm__word[data-state=load] {
opacity: 1;
}
}// 실제 동작: 카드는 그대로 두고 상태만 바꾼다. 기다리는 동안은 aria-busy 로 알리고, 끝나면 成功·실패를 번갈아 준다
var root = document.querySelector('.pm');
var card = document.getElementById('pm-card');
var timer = 0, turn = 0;
function set(state) {
card.setAttribute('data-state', state);
card.setAttribute('aria-busy', state === 'load' ? 'true' : 'false');
}
document.getElementById('pm-go').addEventListener('click', function () {
root.classList.remove('is-demo');
set('load');
clearTimeout(timer);
timer = setTimeout(function () { set(turn++ % 2 ? 'bad' : 'ok'); }, 1500);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PromiseToast.module.scss";
export type PromiseState = "load" | "ok" | "bad";
export type PromiseToastProps = {
words?: Record<PromiseState, string>;
sub?: string;
goLabel?: string;
wait?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--easing"?: string; "--color"?: string };
const ORDER: PromiseState[] = ["load", "ok", "bad"];
const ICON: Record<PromiseState, any> = {
load: <g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" /><path d="M232 128a104 104 0 0 1-208 0c0-41 23.81-78.36 60.66-95.27a8 8 0 0 1 6.68 14.54C60.15 61.59 40 93.27 40 128a88 88 0 0 0 176 0c0-34.73-20.15-66.41-51.34-80.73a8 8 0 0 1 6.68-14.54C208.19 49.64 232 87 232 128" /></g>,
ok: <g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" /><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" /></g>,
bad: <g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" /><path d="M165.66 101.66L139.31 128l26.35 26.34a8 8 0 0 1-11.32 11.32L128 139.31l-26.34 26.35a8 8 0 0 1-11.32-11.32L116.69 128l-26.35-26.34a8 8 0 0 1 11.32-11.32L128 116.69l26.34-26.35a8 8 0 0 1 11.32 11.32M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" /></g>,
};
export default function PromiseToast({
words = { load: "Uploading", ok: "Upload finished", bad: "Upload dropped" },
sub = "12 photos · 34MB",
goLabel = "Upload again",
wait = 1500,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#6ec8ff",
}: PromiseToastProps) {
const [demo, setDemo] = useState(true);
const [state, setState] = useState<PromiseState>("load");
const turn = useRef<number[]>([0]);
const timer = useRef<number[]>([0]);
const vars: Vars = { "--easing": easing, "--color": color };
useEffect(() => () => { clearTimeout((timer.current ?? [0])[0]); }, []);
function start() {
setDemo(false);
setState("load");
const t = timer.current ?? [0];
const n = turn.current ?? [0];
clearTimeout(t[0]);
t[0] = setTimeout(() => { setState(n[0]++ % 2 ? "bad" : "ok"); }, wait) as unknown as number;
}
return (
<div className={`${styles.pm} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.card} data-state={state} role="status" aria-live="polite" aria-busy={state === "load"}>
<span className={styles.slot} aria-hidden="true">
{ORDER.map((s) => (
<span key={s} className={styles.face} data-state={s} data-on={!demo && s === state ? "1" : "0"}>
{s === "load"
? <span className={styles.spin}><svg viewBox="0 0 256 256" width="30" height="30">{ICON[s]}</svg></span>
: <svg viewBox="0 0 256 256" width="30" height="30">{ICON[s]}</svg>}
</span>
))}
</span>
<span className={styles.txt}>
<span className={styles.words}>
{ORDER.map((s) => (
<span key={s} className={styles.word} data-state={s} data-on={!demo && s === state ? "1" : "0"}>{words[s]}</span>
))}
</span>
<span className={styles.sub}>{sub}</span>
</span>
</div>
<button className={styles.go} type="button" onClick={start}>{goLabel}</button>
</div>
);
}06 · 読み込みから結果へ変わるトースト — 計測 累積面積 1.167% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
待っている間はカードの aria-busy が true になり、答えが出ると false に変わります。
.pm__spin {
animation-name: pm-spin;
animation-duration: 1s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pm-spin {
0% { rotate: 0deg; }
100% { rotate: 360deg; }
}
@keyframes pm-load {
0% { opacity: 1; }
35% { opacity: 0; }
56% { opacity: 1; }
77% { opacity: 0; }
89% { opacity: 1; }
100% { opacity: 1; }
}
07三件を超えると畳まれる束
何が見えるか
カードはそれぞれ自分の順番を --i に持っていて、畳んだ形と開いた形の違いは、その数に掛ける間隔と縮尺だけです。畳んだときは 12px ずつずれて scale が少しずつ小さくなりデッキのように見え、開くと 36px の階段になって全部が同じ大きさに戻ります。

<main class="stage">
<div class="cg is-demo">
<div class="cg__box">
<ul class="cg__list" id="cg-list" aria-label="まとまった通知">
<li class="cg__item" style="--i:3"><span class="cg__in">
<span class="cg__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
<span class="cg__msg">ポイントが入りました</span>
</span></li>
<li class="cg__item" style="--i:2"><span class="cg__in">
<span class="cg__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M144 176a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m88-48A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88m-92-32a12 12 0 1 0-12-12a12 12 0 0 0 12 12"/></g></svg></span>
<span class="cg__msg">クーポンが届きました</span>
</span></li>
<li class="cg__item" style="--i:1"><span class="cg__in">
<span class="cg__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M240 128a80 80 0 0 1-80 80H72A56 56 0 1 1 85.92 97.74v.1A80 80 0 0 1 240 128" opacity=".2"/><path d="M178.34 165.66L160 147.31V208a8 8 0 0 1-16 0v-60.69l-18.34 18.35a8 8 0 0 1-11.32-11.32l32-32a8 8 0 0 1 11.32 0l32 32a8 8 0 0 1-11.32 11.32M160 40a88.08 88.08 0 0 0-78.71 48.68A64 64 0 1 0 72 216h40a8 8 0 0 0 0-16H72a48 48 0 0 1 0-96c1.1 0 2.2 0 3.29.12A88 88 0 0 0 72 128a8 8 0 0 0 16 0a72 72 0 1 1 100.8 66a8 8 0 0 0 3.2 15.34a7.9 7.9 0 0 0 3.2-.68A88 88 0 0 0 160 40"/></g></svg></span>
<span class="cg__msg">配送が始まりました</span>
</span></li>
<li class="cg__item" style="--i:0"><span class="cg__in">
<span class="cg__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg></span>
<span class="cg__msg">決済が承認されました</span>
<span class="cg__more" id="cg-more" aria-hidden="true">+3</span>
</span></li>
</ul>
</div>
<button class="cg__toggle" type="button" id="cg-toggle" aria-expanded="false" aria-controls="cg-list">
<span class="cg__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg></span>
まとまった通知を開く
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.cg {
box-sizing: border-box;
width: min(384px, 88vw);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
--step: 36px;
--fold: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.cg__box {
position: relative;
width: 100%;
height: 150px;
}
.cg__list {
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 0;
list-style: none;
}
// 접혔을 때는 뒤 카드일수록 살짝 위로 올라가고 작아진다 — 덱처럼 보이게 하는 두 값이 전부다
.cg__item {
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 0;
z-index: calc(9 - var(--i));
transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * .04));
transition: transform $duration $easing;
cursor: pointer;
}
.cg__list.is-open .cg__item { transform: translateY(calc(var(--i) * var(--step) * -1)) scale(1); }
.cg__in {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
box-shadow: $shadow-press;
}
.cg__ic { display: inline-flex; flex: 0 0 auto; color: $color; }
.cg__ic svg { display: block; }
.cg__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 12px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cg__more {
flex: 0 0 auto;
padding: 0 $sp-2;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-size: 11px;
line-height: 18px;
font-weight: 700;
}
.cg__list.is-open .cg__more { opacity: 0; }
.cg__toggle {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: $sp-2;
margin: 0;
padding: $sp-1 $sp-4;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-size: 12px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cg__caret { display: inline-flex; transition: transform $duration $easing; }
.cg__caret svg { display: block; }
.cg__toggle[aria-expanded="true"] .cg__caret { transform: rotate(180deg); }
.cg__toggle:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// ── 자동 루프: 접힘 → 계단으로 펼침 → 다시 접힘. 네 장이 늘 화면에 남는다 ──
.cg.is-demo .cg__item {
transition: none;
animation-name: cg-fan;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cg-fan {
0%, 26% { transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * .04)); }
42% { transform: translateY(calc(var(--i) * var(--step) * -1)) scale(1); }
68% { transform: translateY(calc(var(--i) * var(--step) * -1)) scale(1); }
84% { transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * .04)); }
90%, 100% { transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * .04)); }
}
// 남은 개수 배지와 화살표는 샘플 프레임 사이에서 끊어 바꾼다
.cg.is-demo .cg__more {
animation-name: cg-more;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cg-more {
0% { opacity: 1; }
31% { opacity: 0; }
81% { opacity: 1; }
100% { opacity: 1; }
}
.cg.is-demo .cg__caret {
transition: none;
animation-name: cg-caret;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cg-caret {
0%, 26% { transform: rotate(0deg); }
42% { transform: rotate(180deg); }
68% { transform: rotate(180deg); }
84% { transform: rotate(0deg); }
90%, 100% { transform: rotate(0deg); }
}
@media (max-width: 340px) {
.cg { gap: $sp-2; --step: 28px; --fold: 8px; }
.cg__box { height: 118px; }
.cg__in { padding: $sp-1 $sp-2; }
.cg__msg { font-size: 11px; line-height: 16px; }
.cg__more { font-size: 10px; line-height: 16px; }
.cg__toggle { padding: 0 $sp-3; font-size: 11px; line-height: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.cg.is-demo .cg__item,
.cg.is-demo .cg__more,
.cg.is-demo .cg__caret { animation: none !important; }
.cg__item,
.cg__caret { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cg {
box-sizing: border-box;
width: min(384px, 88vw);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
--step: 36px;
--fold: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.cg__box {
position: relative;
width: 100%;
height: 150px;
}
.cg__list {
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 0;
list-style: none;
}
.cg__item {
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 0;
z-index: calc(9 - var(--i));
transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * 0.04));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
cursor: pointer;
}
.cg__list.is-open .cg__item {
transform: translateY(calc(var(--i) * var(--step) * -1)) scale(1);
}
.cg__in {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.cg__ic {
display: inline-flex;
flex: 0 0 auto;
color: #2f6df6;
}
.cg__ic svg {
display: block;
}
.cg__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 12px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cg__more {
flex: 0 0 auto;
padding: 0 8px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-size: 11px;
line-height: 18px;
font-weight: 700;
}
.cg__list.is-open .cg__more {
opacity: 0;
}
.cg__toggle {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 4px 16px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-size: 12px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cg__caret {
display: inline-flex;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cg__caret svg {
display: block;
}
.cg__toggle[aria-expanded=true] .cg__caret {
transform: rotate(180deg);
}
.cg__toggle:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.cg.is-demo .cg__item {
transition: none;
animation-name: cg-fan;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes cg-fan {
0%, 26% {
transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * 0.04));
}
42% {
transform: translateY(calc(var(--i) * var(--step) * -1)) scale(1);
}
68% {
transform: translateY(calc(var(--i) * var(--step) * -1)) scale(1);
}
84% {
transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * 0.04));
}
90%, 100% {
transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * 0.04));
}
}
.cg.is-demo .cg__more {
animation-name: cg-more;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cg-more {
0% {
opacity: 1;
}
31% {
opacity: 0;
}
81% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.cg.is-demo .cg__caret {
transition: none;
animation-name: cg-caret;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes cg-caret {
0%, 26% {
transform: rotate(0deg);
}
42% {
transform: rotate(180deg);
}
68% {
transform: rotate(180deg);
}
84% {
transform: rotate(0deg);
}
90%, 100% {
transform: rotate(0deg);
}
}
@media (max-width: 340px) {
.cg {
gap: 8px;
--step: 28px;
--fold: 8px;
}
.cg__box {
height: 118px;
}
.cg__in {
padding: 4px 8px;
}
.cg__msg {
font-size: 11px;
line-height: 16px;
}
.cg__more {
font-size: 10px;
line-height: 16px;
}
.cg__toggle {
padding: 0 12px;
font-size: 11px;
line-height: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.cg.is-demo .cg__item,
.cg.is-demo .cg__more,
.cg.is-demo .cg__caret {
animation: none !important;
}
.cg__item,
.cg__caret {
transition: none;
}
}// 실제 동작: 펼침 상태는 aria-expanded 하나가 정하고, 카드 위치는 각자의 --i 와 그 상태를 곱해 계산한다
var root = document.querySelector('.cg');
var list = document.getElementById('cg-list');
var btn = document.getElementById('cg-toggle');
function toggle() {
root.classList.remove('is-demo');
var open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!open));
list.classList.toggle('is-open', !open);
}
btn.addEventListener('click', toggle);
list.addEventListener('click', function (e) {
if (e.target.closest('.cg__item')) toggle(); // 앞 카드를 눌러도 펼쳐진다
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CollapsedGroup.module.scss";
export type CollapsedGroupProps = {
items?: string[];
listLabel?: string;
toggleLabel?: string;
step?: number;
fold?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--step"?: string; "--fold"?: string; "--duration"?: string; "--easing"?: string; "--color"?: string };
type ItemVars = CSSProperties & { "--i"?: number };
const RING = <g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" /><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" /></g>;
export default function CollapsedGroup({
items = ["Payment approved", "Shipping started", "A coupon arrived", "Points credited"],
listLabel = "Grouped notifications",
toggleLabel = "Open the grouped notifications",
step = 36,
fold = 12,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: CollapsedGroupProps) {
const [demo, setDemo] = useState(true);
const [open, setOpen] = useState(false);
const vars: Vars = {
"--step": `${step}px`, "--fold": `${fold}px`,
"--duration": `${duration}ms`, "--easing": easing, "--color": color,
};
function toggle() {
setDemo(false);
setOpen((v) => !v);
}
// 맨 앞 카드가 --i 0 이 되도록 뒤집어 그린다
const deck = items.slice(0, 4).map((text, i) => ({ text, i: items.length - 1 - i })).reverse();
return (
<div className={`${styles.cg} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.box}>
<ul className={`${styles.list} ${open ? styles.isOpen : ""}`} id="cg-list" aria-label={listLabel}>
{deck.map((row) => {
const iv: ItemVars = { "--i": row.i };
return (
<li key={row.text} className={styles.item} style={iv} onClick={toggle}>
<span className={styles.in}>
<span className={styles.ic} aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16">{RING}</svg></span>
<span className={styles.msg}>{row.text}</span>
{row.i === 0 ? <span className={styles.more} aria-hidden="true">{`+${deck.length - 1}`}</span> : null}
</span>
</li>
);
})}
</ul>
</div>
<button className={styles.toggle} type="button" aria-expanded={open} aria-controls="cg-list" onClick={toggle}>
<span className={styles.caret} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2" /><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z" /></g></svg>
</span>
{toggleLabel}
</button>
</div>
);
}07 · 三件を超えると畳まれる束 — 計測 累積面積 24.119% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
重なり順は z-index: calc(9 - var(--i)) の一行が引き受けます。
.cg__item {
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 0;
z-index: calc(9 - var(--i));
transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * .04));
transition: transform $duration $easing;
cursor: pointer;
}
.cg__list.is-open .cg__item { transform: translateY(calc(var(--i) * var(--step) * -1)) scale(1); }
08六か所の表示位置切り替え
画面で起きること
窓の data-pos の値ひとつが通知の束の inset と transform を同時に変え、上下3か所ずつ計6か所のどれかへ寄せます。中央の位置は inset の左の値を 50% にして translate(-50%) と、右側の位置は calc(100% - 8px) にして translate(-100%) と組み合わせてあるので、束の幅を知らなくても計算が合います。

<main class="stage">
<div class="ps is-demo">
<div class="ps__screen" id="ps-screen" data-pos="br">
<span class="ps__bar" aria-hidden="true"><i></i><i></i><i></i></span>
<div class="ps__toasts" id="ps-toasts" role="region" aria-label="通知が出る位置">
<span class="ps__toast">
<span class="ps__dot" aria-hidden="true"></span>
<span class="ps__msg">リンクをコピー</span>
</span>
<span class="ps__toast">
<span class="ps__dot" aria-hidden="true"></span>
<span class="ps__msg">下書きを保存</span>
</span>
</div>
</div>
<div class="ps__pads" role="group" aria-label="通知の位置を選ぶ">
<button class="ps__pad" type="button" data-pos="tl" aria-pressed="false" aria-label="左上"><span></span></button>
<button class="ps__pad" type="button" data-pos="tc" aria-pressed="false" aria-label="中央上"><span></span></button>
<button class="ps__pad" type="button" data-pos="tr" aria-pressed="false" aria-label="右上"><span></span></button>
<button class="ps__pad" type="button" data-pos="bl" aria-pressed="false" aria-label="左下"><span></span></button>
<button class="ps__pad" type="button" data-pos="bc" aria-pressed="false" aria-label="中央下"><span></span></button>
<button class="ps__pad" type="button" data-pos="br" aria-pressed="true" aria-label="右下"><span></span></button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.ps {
box-sizing: border-box;
width: min(376px, 88vw);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
// 가짜 브라우저 창 — 알림이 어느 구석에 붙는지 보여 주는 기준틀이다
.ps__screen {
position: relative;
box-sizing: border-box;
width: 100%;
height: 136px;
overflow: hidden;
border: 2px solid rgba(23, 20, 26, .16);
border-radius: $r-card;
background: $subject-cream;
}
.ps__bar {
position: absolute;
left: 0;
right: 0;
top: 0;
display: flex;
gap: $sp-1;
padding: $sp-2 $sp-3;
background: rgba(23, 20, 26, .06);
}
.ps__bar i {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(23, 20, 26, .22);
}
.ps__toasts {
position: absolute;
inset: calc(100% - 8px) auto auto calc(100% - 8px);
display: flex;
flex-direction: column;
gap: $sp-1;
width: 148px;
transform: translate(-100%, -100%);
transition: inset $duration $easing, transform $duration $easing;
}
.ps__screen[data-pos="tl"] .ps__toasts { inset: 30px auto auto 8px; transform: translate(0, 0); }
.ps__screen[data-pos="tc"] .ps__toasts { inset: 30px auto auto 50%; transform: translate(-50%, 0); }
.ps__screen[data-pos="tr"] .ps__toasts { inset: 30px auto auto calc(100% - 8px); transform: translate(-100%, 0); }
.ps__screen[data-pos="bl"] .ps__toasts { inset: calc(100% - 8px) auto auto 8px; transform: translate(0, -100%); }
.ps__screen[data-pos="bc"] .ps__toasts { inset: calc(100% - 8px) auto auto 50%; transform: translate(-50%, -100%); }
.ps__screen[data-pos="br"] .ps__toasts { inset: calc(100% - 8px) auto auto calc(100% - 8px); transform: translate(-100%, -100%); }
.ps__toast {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
background: $subject-ink;
color: $subject-cream;
box-shadow: $shadow-press;
}
.ps__dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
background: $color;
}
.ps__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 11px;
line-height: 16px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ps__pads {
display: grid;
grid-template-columns: repeat(3, 40px);
gap: $sp-2;
}
.ps__pad {
box-sizing: border-box;
display: flex;
margin: 0;
padding: $sp-1;
height: 24px;
border: 2px solid rgba(23, 20, 26, .2);
border-radius: $r-sm;
background: transparent;
cursor: pointer;
}
.ps__pad span {
display: block;
width: 14px;
height: 6px;
border-radius: $r-xs;
background: $color;
}
.ps__pad[data-pos="tl"] span,
.ps__pad[data-pos="bl"] span { margin-right: auto; }
.ps__pad[data-pos="tc"] span,
.ps__pad[data-pos="bc"] span { margin: 0 auto; }
.ps__pad[data-pos="tr"] span,
.ps__pad[data-pos="br"] span { margin-left: auto; }
.ps__pad[data-pos="tl"],
.ps__pad[data-pos="tc"],
.ps__pad[data-pos="tr"] { align-items: flex-start; }
.ps__pad[data-pos="bl"],
.ps__pad[data-pos="bc"],
.ps__pad[data-pos="br"] { align-items: flex-end; }
.ps__pad[aria-pressed="true"] { border-color: $subject-ink; background: $subject-ink; }
.ps__pad:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 자동 루프: 여섯 자리를 차례로 돈다. 묶음은 늘 화면 안에 있고 자리만 옮겨 간다 ──
.ps.is-demo .ps__toasts {
transition: none;
animation-name: ps-move;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes ps-move {
0%, 10% { inset: 30px auto auto 8px; transform: translate(0, 0); }
16.67%, 27% { inset: 30px auto auto 50%; transform: translate(-50%, 0); }
33.34%, 44% { inset: 30px auto auto calc(100% - 8px); transform: translate(-100%, 0); }
50%, 61% { inset: calc(100% - 8px) auto auto calc(100% - 8px); transform: translate(-100%, -100%); }
66.67%, 78% { inset: calc(100% - 8px) auto auto 50%; transform: translate(-50%, -100%); }
83.34%, 90% { inset: calc(100% - 8px) auto auto 8px; transform: translate(0, -100%); }
100% { inset: 30px auto auto 8px; transform: translate(0, 0); }
}
// 눌린 자리 표시도 같은 시계로 돈다 — 여섯 개가 1/6 씩 돌아가며 켜진다
.ps.is-demo .ps__pad {
animation-name: ps-pad;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ps.is-demo .ps__pad[data-pos="tl"] { animation-delay: 0s; }
.ps.is-demo .ps__pad[data-pos="tc"] { animation-delay: -1.66667s; }
.ps.is-demo .ps__pad[data-pos="tr"] { animation-delay: -1.33333s; }
.ps.is-demo .ps__pad[data-pos="br"] { animation-delay: -1s; }
.ps.is-demo .ps__pad[data-pos="bc"] { animation-delay: -0.66667s; }
.ps.is-demo .ps__pad[data-pos="bl"] { animation-delay: -0.33333s; }
@keyframes ps-pad {
0% { border-color: $subject-ink; background-color: $subject-ink; }
16.67% { border-color: rgba(23, 20, 26, .2); background-color: rgba(23, 20, 26, 0); }
100% { border-color: rgba(23, 20, 26, .2); background-color: rgba(23, 20, 26, 0); }
}
@media (max-width: 340px) {
.ps { gap: $sp-2; }
.ps__screen { height: 108px; }
.ps__toasts { width: 118px; }
.ps__msg { font-size: 10px; line-height: 14px; }
.ps__pads { grid-template-columns: repeat(3, 34px); gap: $sp-1; }
.ps__pad { height: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.ps.is-demo .ps__toasts,
.ps.is-demo .ps__pad { animation: none !important; }
.ps__toasts { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.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);
}
.ps {
box-sizing: border-box;
width: min(376px, 88vw);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.ps__screen {
position: relative;
box-sizing: border-box;
width: 100%;
height: 136px;
overflow: hidden;
border: 2px solid rgba(23, 20, 26, 0.16);
border-radius: 16px;
background: #fff7e6;
}
.ps__bar {
position: absolute;
left: 0;
right: 0;
top: 0;
display: flex;
gap: 4px;
padding: 8px 12px;
background: rgba(23, 20, 26, 0.06);
}
.ps__bar i {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.22);
}
.ps__toasts {
position: absolute;
inset: calc(100% - 8px) auto auto calc(100% - 8px);
display: flex;
flex-direction: column;
gap: 4px;
width: 148px;
transform: translate(-100%, -100%);
transition: inset 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ps__screen[data-pos=tl] .ps__toasts {
inset: 30px auto auto 8px;
transform: translate(0, 0);
}
.ps__screen[data-pos=tc] .ps__toasts {
inset: 30px auto auto 50%;
transform: translate(-50%, 0);
}
.ps__screen[data-pos=tr] .ps__toasts {
inset: 30px auto auto calc(100% - 8px);
transform: translate(-100%, 0);
}
.ps__screen[data-pos=bl] .ps__toasts {
inset: calc(100% - 8px) auto auto 8px;
transform: translate(0, -100%);
}
.ps__screen[data-pos=bc] .ps__toasts {
inset: calc(100% - 8px) auto auto 50%;
transform: translate(-50%, -100%);
}
.ps__screen[data-pos=br] .ps__toasts {
inset: calc(100% - 8px) auto auto calc(100% - 8px);
transform: translate(-100%, -100%);
}
.ps__toast {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 8px;
background: #17141a;
color: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ps__dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
background: #2f6df6;
}
.ps__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 11px;
line-height: 16px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ps__pads {
display: grid;
grid-template-columns: repeat(3, 40px);
gap: 8px;
}
.ps__pad {
box-sizing: border-box;
display: flex;
margin: 0;
padding: 4px;
height: 24px;
border: 2px solid rgba(23, 20, 26, 0.2);
border-radius: 8px;
background: transparent;
cursor: pointer;
}
.ps__pad span {
display: block;
width: 14px;
height: 6px;
border-radius: 4px;
background: #2f6df6;
}
.ps__pad[data-pos=tl] span,
.ps__pad[data-pos=bl] span {
margin-right: auto;
}
.ps__pad[data-pos=tc] span,
.ps__pad[data-pos=bc] span {
margin: 0 auto;
}
.ps__pad[data-pos=tr] span,
.ps__pad[data-pos=br] span {
margin-left: auto;
}
.ps__pad[data-pos=tl],
.ps__pad[data-pos=tc],
.ps__pad[data-pos=tr] {
align-items: flex-start;
}
.ps__pad[data-pos=bl],
.ps__pad[data-pos=bc],
.ps__pad[data-pos=br] {
align-items: flex-end;
}
.ps__pad[aria-pressed=true] {
border-color: #17141a;
background: #17141a;
}
.ps__pad:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ps.is-demo .ps__toasts {
transition: none;
animation-name: ps-move;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes ps-move {
0%, 10% {
inset: 30px auto auto 8px;
transform: translate(0, 0);
}
16.67%, 27% {
inset: 30px auto auto 50%;
transform: translate(-50%, 0);
}
33.34%, 44% {
inset: 30px auto auto calc(100% - 8px);
transform: translate(-100%, 0);
}
50%, 61% {
inset: calc(100% - 8px) auto auto calc(100% - 8px);
transform: translate(-100%, -100%);
}
66.67%, 78% {
inset: calc(100% - 8px) auto auto 50%;
transform: translate(-50%, -100%);
}
83.34%, 90% {
inset: calc(100% - 8px) auto auto 8px;
transform: translate(0, -100%);
}
100% {
inset: 30px auto auto 8px;
transform: translate(0, 0);
}
}
.ps.is-demo .ps__pad {
animation-name: ps-pad;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ps.is-demo .ps__pad[data-pos=tl] {
animation-delay: 0s;
}
.ps.is-demo .ps__pad[data-pos=tc] {
animation-delay: -1.66667s;
}
.ps.is-demo .ps__pad[data-pos=tr] {
animation-delay: -1.33333s;
}
.ps.is-demo .ps__pad[data-pos=br] {
animation-delay: -1s;
}
.ps.is-demo .ps__pad[data-pos=bc] {
animation-delay: -0.66667s;
}
.ps.is-demo .ps__pad[data-pos=bl] {
animation-delay: -0.33333s;
}
@keyframes ps-pad {
0% {
border-color: #17141a;
background-color: #17141a;
}
16.67% {
border-color: rgba(23, 20, 26, 0.2);
background-color: rgba(23, 20, 26, 0);
}
100% {
border-color: rgba(23, 20, 26, 0.2);
background-color: rgba(23, 20, 26, 0);
}
}
@media (max-width: 340px) {
.ps {
gap: 8px;
}
.ps__screen {
height: 108px;
}
.ps__toasts {
width: 118px;
}
.ps__msg {
font-size: 10px;
line-height: 14px;
}
.ps__pads {
grid-template-columns: repeat(3, 34px);
gap: 4px;
}
.ps__pad {
height: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.ps.is-demo .ps__toasts,
.ps.is-demo .ps__pad {
animation: none !important;
}
.ps__toasts {
transition: none;
}
}// 실제 동작: 화면의 data-pos 한 글자쌍이 알림 묶음의 inset 과 쌓이는 방향을 한꺼번에 정한다
var root = document.querySelector('.ps');
var screen = document.getElementById('ps-screen');
var pads = [].slice.call(document.querySelectorAll('.ps__pad'));
pads.forEach(function (b) {
b.addEventListener('click', function () {
var pos = b.getAttribute('data-pos');
root.classList.remove('is-demo');
screen.setAttribute('data-pos', pos);
pads.forEach(function (o) { o.setAttribute('aria-pressed', String(o.getAttribute('data-pos') === pos)); });
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PositionSwitch.module.scss";
export type ToastPos = "tl" | "tc" | "tr" | "bl" | "bc" | "br";
export type PositionSwitchProps = {
messages?: string[];
regionLabel?: string;
groupLabel?: string;
padLabels?: Record<ToastPos, string>;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const PADS: ToastPos[] = ["tl", "tc", "tr", "bl", "bc", "br"];
export default function PositionSwitch({
messages = ["Link copied", "Draft saved"],
regionLabel = "Where toasts appear",
groupLabel = "Pick where toasts appear",
padLabels = { tl: "Top left", tc: "Top center", tr: "Top right", bl: "Bottom left", bc: "Bottom center", br: "Bottom right" },
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: PositionSwitchProps) {
const [demo, setDemo] = useState(true);
const [pos, setPos] = useState<ToastPos>("br");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.ps} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.screen} data-pos={pos}>
<span className={styles.bar} aria-hidden="true"><i /><i /><i /></span>
<div className={styles.toasts} role="region" aria-label={regionLabel}>
{messages.map((m) => (
<span key={m} className={styles.toast}>
<span className={styles.dot} aria-hidden="true" />
<span className={styles.msg}>{m}</span>
</span>
))}
</div>
</div>
<div className={styles.pads} role="group" aria-label={groupLabel}>
{PADS.map((p) => (
<button
key={p}
className={styles.pad}
type="button"
data-pos={p}
aria-pressed={!demo && p === pos}
aria-label={padLabels[p]}
onClick={() => { setDemo(false); setPos(p); }}
>
<span />
</button>
))}
</div>
</div>
);
}08 · 六か所の表示位置切り替え — 計測 累積面積 29.232% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
2つの値を同じ時間で移すから、位置が変わるときに飛ばずに滑ります。
.ps__toasts {
position: absolute;
inset: calc(100% - 8px) auto auto calc(100% - 8px);
display: flex;
flex-direction: column;
gap: $sp-1;
width: 148px;
transform: translate(-100%, -100%);
transition: inset $duration $easing, transform $duration $easing;
}
.ps__screen[data-pos="tl"] .ps__toasts { inset: 30px auto auto 8px; transform: translate(0, 0); }
.ps__screen[data-pos="tc"] .ps__toasts { inset: 30px auto auto 50%; transform: translate(-50%, 0); }
.ps__screen[data-pos="tr"] .ps__toasts { inset: 30px auto auto calc(100% - 8px); transform: translate(-100%, 0); }
.ps__screen[data-pos="br"] .ps__toasts { inset: calc(100% - 8px) auto auto calc(100% - 8px); transform: translate(-100%, -100%); }
09読み上げ順を分けるライブリージョン
どんな動きか
左のレーンは読み上げ中の文が終わるのを待ってから読まれ、右のレーンは読んでいる途中でも割り込みます。違いは役割名と aria-live の値の組み合わせだけなので、エラーだけを右へ送れば残りの通知が読み上げのじゃまをしません。

<main class="stage">
<div class="lr is-demo">
<div class="lr__grid">
<div class="lr__col is-live" data-lane="polite">
<span class="lr__tag"><code>role="status"</code> 順番を待つ</span>
<p class="lr__card" role="status" aria-live="polite" aria-atomic="true" aria-label="丁寧な通知のレーン">
<span class="lr__dot" aria-hidden="true"></span>
<span class="lr__msg">メールを送りました</span>
</p>
</div>
<div class="lr__col" data-lane="alert">
<span class="lr__tag"><code>role="alert"</code> 割り込む</span>
<p class="lr__card" role="alert" aria-live="assertive" aria-atomic="true" aria-label="緊急の通知のレーン">
<span class="lr__dot" aria-hidden="true"></span>
<span class="lr__msg">決済が拒否されました</span>
</p>
</div>
</div>
<div class="lr__say" id="lr-say" data-lane="polite">
<span class="lr__wave" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="lr__saycap">読み上げ中</span>
<span class="lr__saytxt">
<span class="lr__sayone" data-lane="polite">メールを送りました</span>
<span class="lr__sayone" data-lane="alert">決済が拒否されました</span>
</span>
</div>
<div class="lr__btns">
<button class="lr__btn" type="button" data-lane="polite">通常の通知</button>
<button class="lr__btn" type="button" data-lane="alert">エラー通知</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-mint;
@include stage($stage-ink);
.lr {
box-sizing: border-box;
width: min(404px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
}
.lr__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $sp-2;
}
.lr__col { display: flex; flex-direction: column; gap: $sp-1; }
.lr__tag {
display: block;
font-size: 9px;
line-height: 14px;
font-weight: 700;
color: rgba(255, 247, 230, .5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lr__tag code {
font-family: ui-monospace, "Consolas", monospace;
font-size: 9px;
color: rgba(255, 247, 230, .78);
}
.lr__card {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
margin: 0;
padding: $sp-2 $sp-2;
border: 2px solid rgba(255, 255, 255, .1);
border-radius: $r-control;
background: rgba(255, 255, 255, .06);
}
.lr__dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 247, 230, .28);
}
.lr__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 11px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// 지금 읽히고 있는 줄만 테두리가 켜진다
.lr__col.is-live .lr__card { border-color: $color; background: rgba(76, 212, 166, .12); }
.lr__col.is-live .lr__dot { background: $color; }
.lr__col[data-lane="alert"].is-live .lr__card { border-color: $stage-orange; background: rgba(255, 77, 31, .14); }
.lr__col[data-lane="alert"].is-live .lr__dot { background: $stage-orange; }
.lr__say {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
}
.lr__wave { display: inline-flex; align-items: center; gap: $sp-1; flex: 0 0 auto; height: 14px; }
.lr__wave i {
display: block;
width: 3px;
height: 14px;
border-radius: $r-xs;
background: $subject-blue;
transform: scaleY(.3);
}
.lr__saycap {
flex: 0 0 auto;
font-size: 9px;
line-height: 18px;
font-weight: 700;
color: rgba($stage-ink, .72);
}
.lr__saytxt { position: relative; display: block; flex: 1 1 auto; min-width: 0; height: 18px; }
.lr__sayone {
position: absolute;
left: 0;
right: 0;
top: 0;
opacity: 0;
font-size: 12px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lr__say[data-lane="polite"] .lr__sayone[data-lane="polite"],
.lr__say[data-lane="alert"] .lr__sayone[data-lane="alert"] { opacity: 1; }
.lr__say[data-lane="alert"] .lr__sayone[data-lane="alert"] { color: $error; }
.lr__btns { display: flex; gap: $sp-2; }
.lr__btn {
box-sizing: border-box;
flex: 1 1 0;
margin: 0;
padding: $sp-1 $sp-2;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .1);
color: $subject-cream;
font-family: inherit;
font-size: 11px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.lr__btn[data-lane="alert"] { background: $stage-orange; color: $subject-ink; }
.lr__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 자동 루프: 정중한 줄을 읽다가 오류가 들어와 읽던 문장을 끊고 넘어간다 ──
// 읽는 막대는 늘 움직이고 카드는 사라지지 않으므로 어느 프레임도 빈 화면이 아니다
.lr__wave i {
animation-name: lr-wave;
animation-duration: 600ms;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
animation-direction: alternate;
}
.lr__wave i:nth-child(1) { animation-delay: 0ms; }
.lr__wave i:nth-child(2) { animation-delay: -200ms; }
.lr__wave i:nth-child(3) { animation-delay: -400ms; }
@keyframes lr-wave {
0% { transform: scaleY(.24); }
100% { transform: scaleY(1); }
}
// 켜진 줄과 읽히는 문장은 샘플 프레임 사이(45% · 89%)에서 끊어 바꾼다
.lr.is-demo .lr__card,
.lr.is-demo .lr__dot,
.lr.is-demo .lr__sayone {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lr.is-demo .lr__col[data-lane="polite"] .lr__card { animation-name: lr-on-polite; }
.lr.is-demo .lr__col[data-lane="polite"] .lr__dot { animation-name: lr-dot-polite; }
.lr.is-demo .lr__col[data-lane="alert"] .lr__card { animation-name: lr-on-alert; }
.lr.is-demo .lr__col[data-lane="alert"] .lr__dot { animation-name: lr-dot-alert; }
.lr.is-demo .lr__sayone[data-lane="polite"] { animation-name: lr-say-polite; }
.lr.is-demo .lr__sayone[data-lane="alert"] { animation-name: lr-say-alert; }
@keyframes lr-on-polite {
0% { border-color: $color; background-color: rgba(76, 212, 166, .12); }
45% { border-color: rgba(255, 255, 255, .1); background-color: rgba(255, 255, 255, .06); }
89% { border-color: $color; background-color: rgba(76, 212, 166, .12); }
100% { border-color: $color; background-color: rgba(76, 212, 166, .12); }
}
@keyframes lr-dot-polite {
0% { background-color: $color; }
45% { background-color: rgba(255, 247, 230, .28); }
89% { background-color: $color; }
100% { background-color: $color; }
}
@keyframes lr-on-alert {
0% { border-color: rgba(255, 255, 255, .1); background-color: rgba(255, 255, 255, .06); }
45% { border-color: $stage-orange; background-color: rgba(255, 77, 31, .14); }
89% { border-color: rgba(255, 255, 255, .1); background-color: rgba(255, 255, 255, .06); }
100% { border-color: rgba(255, 255, 255, .1); background-color: rgba(255, 255, 255, .06); }
}
@keyframes lr-dot-alert {
0% { background-color: rgba(255, 247, 230, .28); }
45% { background-color: $stage-orange; }
89% { background-color: rgba(255, 247, 230, .28); }
100% { background-color: rgba(255, 247, 230, .28); }
}
@keyframes lr-say-polite {
0% { opacity: 1; }
45% { opacity: 0; }
89% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes lr-say-alert {
0% { opacity: 0; }
45% { opacity: 1; }
89% { opacity: 0; }
100% { opacity: 0; }
}
// 오류가 끼어드는 순간 그 카드가 한 번 튄다
.lr.is-demo .lr__col[data-lane="alert"] .lr__card {
animation-name: lr-on-alert, lr-jolt;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
@keyframes lr-jolt {
0%, 45% { transform: scale(1); }
52% { transform: scale(1.06); }
62% { transform: scale(1); }
90%, 100% { transform: scale(1); }
}
@media (max-width: 340px) {
.lr__card { padding: $sp-1; gap: $sp-1; }
.lr__msg { font-size: 10px; line-height: 16px; }
.lr__say { padding: 0 $sp-2; gap: $sp-1; }
.lr__sayone { font-size: 11px; }
.lr__btn { font-size: 10px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.lr__wave i,
.lr.is-demo .lr__col,
.lr.is-demo .lr__card,
.lr.is-demo .lr__dot,
.lr.is-demo .lr__sayone { animation: none !important; }
.lr.is-demo .lr__sayone[data-lane="polite"] { opacity: 1; }
.lr__wave i { transform: scaleY(.7); }
}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);
}
.lr {
box-sizing: border-box;
width: min(404px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.lr__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.lr__col {
display: flex;
flex-direction: column;
gap: 4px;
}
.lr__tag {
display: block;
font-size: 9px;
line-height: 14px;
font-weight: 700;
color: rgba(255, 247, 230, 0.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lr__tag code {
font-family: ui-monospace, "Consolas", monospace;
font-size: 9px;
color: rgba(255, 247, 230, 0.78);
}
.lr__card {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 8px 8px;
border: 2px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
}
.lr__dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 247, 230, 0.28);
}
.lr__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 11px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lr__col.is-live .lr__card {
border-color: #4cd4a6;
background: rgba(76, 212, 166, 0.12);
}
.lr__col.is-live .lr__dot {
background: #4cd4a6;
}
.lr__col[data-lane=alert].is-live .lr__card {
border-color: #ff4d1f;
background: rgba(255, 77, 31, 0.14);
}
.lr__col[data-lane=alert].is-live .lr__dot {
background: #ff4d1f;
}
.lr__say {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
}
.lr__wave {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
height: 14px;
}
.lr__wave i {
display: block;
width: 3px;
height: 14px;
border-radius: 4px;
background: #2f6df6;
transform: scaleY(0.3);
}
.lr__saycap {
flex: 0 0 auto;
font-size: 9px;
line-height: 18px;
font-weight: 700;
color: rgba(23, 20, 26, 0.72);
}
.lr__saytxt {
position: relative;
display: block;
flex: 1 1 auto;
min-width: 0;
height: 18px;
}
.lr__sayone {
position: absolute;
left: 0;
right: 0;
top: 0;
opacity: 0;
font-size: 12px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lr__say[data-lane=polite] .lr__sayone[data-lane=polite],
.lr__say[data-lane=alert] .lr__sayone[data-lane=alert] {
opacity: 1;
}
.lr__say[data-lane=alert] .lr__sayone[data-lane=alert] {
color: #c2361a;
}
.lr__btns {
display: flex;
gap: 8px;
}
.lr__btn {
box-sizing: border-box;
flex: 1 1 0;
margin: 0;
padding: 4px 8px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
color: #fff7e6;
font-family: inherit;
font-size: 11px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.lr__btn[data-lane=alert] {
background: #ff4d1f;
color: #17141a;
}
.lr__btn:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.lr__wave i {
animation-name: lr-wave;
animation-duration: 600ms;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
animation-direction: alternate;
}
.lr__wave i:nth-child(1) {
animation-delay: 0ms;
}
.lr__wave i:nth-child(2) {
animation-delay: -200ms;
}
.lr__wave i:nth-child(3) {
animation-delay: -400ms;
}
@keyframes lr-wave {
0% {
transform: scaleY(0.24);
}
100% {
transform: scaleY(1);
}
}
.lr.is-demo .lr__card,
.lr.is-demo .lr__dot,
.lr.is-demo .lr__sayone {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lr.is-demo .lr__col[data-lane=polite] .lr__card {
animation-name: lr-on-polite;
}
.lr.is-demo .lr__col[data-lane=polite] .lr__dot {
animation-name: lr-dot-polite;
}
.lr.is-demo .lr__col[data-lane=alert] .lr__card {
animation-name: lr-on-alert;
}
.lr.is-demo .lr__col[data-lane=alert] .lr__dot {
animation-name: lr-dot-alert;
}
.lr.is-demo .lr__sayone[data-lane=polite] {
animation-name: lr-say-polite;
}
.lr.is-demo .lr__sayone[data-lane=alert] {
animation-name: lr-say-alert;
}
@keyframes lr-on-polite {
0% {
border-color: #4cd4a6;
background-color: rgba(76, 212, 166, 0.12);
}
45% {
border-color: rgba(255, 255, 255, 0.1);
background-color: rgba(255, 255, 255, 0.06);
}
89% {
border-color: #4cd4a6;
background-color: rgba(76, 212, 166, 0.12);
}
100% {
border-color: #4cd4a6;
background-color: rgba(76, 212, 166, 0.12);
}
}
@keyframes lr-dot-polite {
0% {
background-color: #4cd4a6;
}
45% {
background-color: rgba(255, 247, 230, 0.28);
}
89% {
background-color: #4cd4a6;
}
100% {
background-color: #4cd4a6;
}
}
@keyframes lr-on-alert {
0% {
border-color: rgba(255, 255, 255, 0.1);
background-color: rgba(255, 255, 255, 0.06);
}
45% {
border-color: #ff4d1f;
background-color: rgba(255, 77, 31, 0.14);
}
89% {
border-color: rgba(255, 255, 255, 0.1);
background-color: rgba(255, 255, 255, 0.06);
}
100% {
border-color: rgba(255, 255, 255, 0.1);
background-color: rgba(255, 255, 255, 0.06);
}
}
@keyframes lr-dot-alert {
0% {
background-color: rgba(255, 247, 230, 0.28);
}
45% {
background-color: #ff4d1f;
}
89% {
background-color: rgba(255, 247, 230, 0.28);
}
100% {
background-color: rgba(255, 247, 230, 0.28);
}
}
@keyframes lr-say-polite {
0% {
opacity: 1;
}
45% {
opacity: 0;
}
89% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes lr-say-alert {
0% {
opacity: 0;
}
45% {
opacity: 1;
}
89% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.lr.is-demo .lr__col[data-lane=alert] .lr__card {
animation-name: lr-on-alert, lr-jolt;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes lr-jolt {
0%, 45% {
transform: scale(1);
}
52% {
transform: scale(1.06);
}
62% {
transform: scale(1);
}
90%, 100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.lr__card {
padding: 4px;
gap: 4px;
}
.lr__msg {
font-size: 10px;
line-height: 16px;
}
.lr__say {
padding: 0 8px;
gap: 4px;
}
.lr__sayone {
font-size: 11px;
}
.lr__btn {
font-size: 10px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.lr__wave i,
.lr.is-demo .lr__col,
.lr.is-demo .lr__card,
.lr.is-demo .lr__dot,
.lr.is-demo .lr__sayone {
animation: none !important;
}
.lr.is-demo .lr__sayone[data-lane=polite] {
opacity: 1;
}
.lr__wave i {
transform: scaleY(0.7);
}
}// 실제 동작: 같은 글자를 다시 넣어야 스크린리더가 한 번 더 읽는다. 오류만 assertive 줄로 보내 읽던 문장을 끊는다
var root = document.querySelector('.lr');
var cols = [].slice.call(document.querySelectorAll('.lr__col'));
var say = document.getElementById('lr-say');
function send(lane) {
root.classList.remove('is-demo');
var col = cols.filter(function (c) { return c.getAttribute('data-lane') === lane; })[0];
cols.forEach(function (c) { c.classList.toggle('is-live', c === col); });
say.setAttribute('data-lane', lane); // 화면에도 지금 읽히는 문장을 그대로 보여 준다
var msg = col.querySelector('.lr__msg');
var text = msg.textContent;
msg.textContent = '';
setTimeout(function () { msg.textContent = text; }, 40);
}
[].forEach.call(document.querySelectorAll('.lr__btn'), function (b) {
b.addEventListener('click', function () { send(b.getAttribute('data-lane')); });
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LiveRegionLanes.module.scss";
export type Lane = "polite" | "alert";
export type LiveRegionLanesProps = {
messages?: Record<Lane, string>;
tags?: Record<Lane, string>;
laneLabels?: Record<Lane, string>;
buttons?: Record<Lane, string>;
sayCap?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--easing"?: string; "--color"?: string };
const LANES: Lane[] = ["polite", "alert"];
export default function LiveRegionLanes({
messages = { polite: "Mail sent", alert: "The payment was declined" },
tags = { polite: "waits its turn", alert: "cuts in" },
laneLabels = { polite: "Polite notification lane", alert: "Urgent notification lane" },
buttons = { polite: "Normal notice", alert: "Error notice" },
sayCap = "reading",
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#4cd4a6",
}: LiveRegionLanesProps) {
const [demo, setDemo] = useState(true);
const [lane, setLane] = useState<Lane>("polite");
const [beat, setBeat] = useState(0);
const [blank, setBlank] = useState<Lane | "">("");
const timer = useRef<number[]>([0]);
const vars: Vars = { "--easing": easing, "--color": color };
useEffect(() => () => { clearTimeout((timer.current ?? [0])[0]); }, []);
// 같은 글자를 비웠다 다시 넣어야 스크린리더가 한 번 더 읽는다
function send(next: Lane) {
setDemo(false);
setLane(next);
setBeat((v) => v + 1);
setBlank(next);
const t = timer.current ?? [0];
clearTimeout(t[0]);
t[0] = setTimeout(() => setBlank(""), 40) as unknown as number;
}
return (
<div className={`${styles.lr} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.grid}>
{LANES.map((l) => (
<div key={l} className={`${styles.col} ${!demo && lane === l ? styles.isLive : ""}`} data-lane={l}>
<span className={styles.tag}><code>{l === "alert" ? 'role="alert"' : 'role="status"'}</code> {tags[l]}</span>
<p
className={styles.card}
role={l === "alert" ? "alert" : "status"}
aria-live={l === "alert" ? "assertive" : "polite"}
aria-atomic="true"
aria-label={laneLabels[l]}
>
<span className={styles.dot} aria-hidden="true" />
<span className={styles.msg} key={`${l}-${beat}`}>{blank === l ? "" : messages[l]}</span>
</p>
</div>
))}
</div>
<div className={styles.say} data-lane={demo ? undefined : lane}>
<span className={styles.wave} aria-hidden="true"><i /><i /><i /></span>
<span className={styles.saycap}>{sayCap}</span>
<span className={styles.saytxt}>
{LANES.map((l) => (
<span key={l} className={styles.sayone} data-lane={l}>{messages[l]}</span>
))}
</span>
</div>
<div className={styles.btns}>
{LANES.map((l) => (
<button key={l} className={styles.btn} type="button" data-lane={l} onClick={() => send(l)}>{buttons[l]}</button>
))}
</div>
</div>
);
}09 · 読み上げ順を分けるライブリージョン — 計測 累積面積 7.215% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
aria-atomic="true" を付けると、変わった部分だけでなく文全体をもう一度読み直してくれます。
<p class="lr__card" role="status" aria-live="polite" aria-atomic="true" aria-label="丁寧な通知のレーン">
<span class="lr__dot" aria-hidden="true"></span>
<span class="lr__msg">メールを送りました</span>
</p>
<p class="lr__card" role="alert" aria-live="assertive" aria-atomic="true" aria-label="緊急の通知のレーン">
<span class="lr__dot" aria-hidden="true"></span>
<span class="lr__msg">決済が拒否されました</span>
</p>
どこで壊れるか — 落とし穴
トーストは消えることが仕事の部品です。そのため9つを 2 秒のループで回したところ、ほかの記事では出なかった問題が見つかりました。このサイトの代表画像は 24 コマのうち「直前のコマといちばん大きく変わった1枚」から選ばれるのですが、トーストのデモでいちばん大きく変わるのは、いつでもカードが消える瞬間です。03 の最初の版がまさにそれで、代表画像はオレンジの地にボタンが1つ残っただけの空白になり、通知が出ている場面は1枚も候補になりませんでした。直し方は単純で、ループの中ではカードを消さないことにしました。バーが尽きるとカードが一度沈み、次の通知が来たようにバーがまた満ちます。閉じる動き自体はボタンと自動タイマーで今も動いています。
2つめは消え方を置く位置です。2 秒を 24 コマに割ると 4.1667% ごとに1枚ずつ拾われるので、消え方をその区切りにまたがせると半透明の幽霊カードが写ります。そこで 06・09 の状態の入れ替えはコマとコマの間(35%・45%・56%・77%・89%)に steps(1, end) のカットとして置き、0.5 秒ごとに顔が替わる 02 も同じ steps(1, end) で中間のコマそのものをなくしました。01 はカードが3枚あるので事情が違います。3枚を一度に消すとその空白がまた代表になるため、78%・82%・86% で1枚ずつ断ち切って抜いています。3つめは大きさです。絶対配置したトーストは親の高さの計算に入らないため、舞台は無事に見えても文書の高さだけが静かに伸びます。だからカードを絶対配置で動かすデモでは、スタックを高さの決まった箱に入れ、はみ出す分を overflow: hidden で切り落としました。320×200 の舞台では余白を引くと縦に使えるのは 174px だけなので、狭い画面ではカードの間隔を 44px から 34px へ縮め、箱の高さも合わせて下げています。トーストが暗い板の上で光る必要があるならダークモード デザインシステム LPがその差を整えます。9つ分のソースが入った圧縮ファイルのパスワードは qh4e8nxm で、いま見えている9つの SCSS と React 版がここで直した値のまま入っています。
アクセシビリティ
動きを減らす設定(prefers-reduced-motion: reduce)が入っていると、9つとも自動再生だけを止め、いまの状態は画面に残します。01 のカードは滑る過程なしに3つの席へそのまま立ち、03 のバーは減るのをやめ、09 の読み上げメーターは高さを固定したまま、どちらのレーンが生きているかは色で示し続けます。押して閉じる操作もマウス任せにはしていません。
| 項目 | マウス | キーボード | 読み上げ |
|---|---|---|---|
| 01 スタック | ボタンを押す | Enter · Space | aria-live="polite" で新しい行だけ |
| 03 進行バー | 乗せると止まる | Tab の焦点でも止まる | 閉じるボタンに aria-label |
| 04 スワイプ | 引いて離す | 左右の矢印でその向きへ | 後ろのカードが残り空白にならない |
| 05 元に戻す | ごみ箱を押す | Tab · Enter | role="status" で削除を知らせる |
| 06 プロミス | ボタンを押す | Enter · Space | aria-busy で進行中かどうか |
| 07 束 | カードかボタンを押す | Enter · Space | aria-expanded |
| 08 位置切り替え | 位置ボタンを押す | Tab · Enter | aria-pressed · 位置ごとに aria-label |
| 09 2つのレーン | ボタンを押す | Enter · Space | role="status" と role="alert" を分ける |
2種類のライブリージョンのうちなぜ role="status" を既定にすべきかは、MDN の status ロールの解説にまとまっています。04 が使うポインターの捕捉も MDN の setPointerCapture の解説に条件が書かれていて、捕まえておかないと指がカードの縁を越えた瞬間に移動イベントが途切れ、カードが途中で止まります。同じように何かを知らせる部品はお知らせカテゴリに、人が押さなくても自分で動く部品は自動実行カテゴリに集めてあります。
よくある質問
スナックバー 通知が同時に5件届いたらどうしますか
画面に置く件数を決めておき、あふれたら古いものから抜くか(01 は3枚で切ります)、07 のように束ねて畳みます。どちらも「何件まで」という数字ひとつを変えれば済むように書いてあります。件数を制限しないと縦に伸びて、画面の下にあるボタンを覆ってしまいます。通り過ぎず留まる表示はcss バッジ デザインの仕事です。
自動で閉じるまでの時間は何秒がよいですか
03 は 4.2 秒にしました。読む量と1行の長さによりますが、操作ボタンの付いた通知は押す時間が要るので 05 のように4秒以上取り、マウスを乗せたら止まるようにしておくほうが安全です。やり直せないエラーは、そもそも自動で閉じない側をおすすめします。
この9つをトーストのライブラリの代わりに使えますか
1つの画面で通知を出す場所が1か所、種類が4〜5種類なら、そのまま使っても無理はありません。スタックの管理も自動で閉じる処理も元に戻すもライブリージョンもここに入っていて、依存がないからです。大きなアプリのどこからでも呼びたい場合やサーバーのイベントとつなぐ場合は、ライブラリのキュー管理は残し、このマークアップを描画の差し込み口に置いて見た目だけ持っていくほうが向いています。









