トースト通知 css アニメーション9選 — JS最小・CSSだけ
トースト通知 css は、画面の隅に短く現れて自分で消えていく通知カードを、JavaScriptをほぼ使わずtransformとopacityだけで動かす手法です。この記事では9種類を実装し、コードは6〜25行の断片として載せています。
01 toast-notification-slide-in-right
02 toast-notification-popover-toast
03 toast-notification-stack-3
04 toast-notification-progress-close
05 toast-notification-undo-toast
06 toast-notification-swipe-dismiss
08 toast-notification-bottom-mobile
09 toast-notification-bell-badge
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 右からスライドイン
- 02 Popover APIトースト
- 03 3件スタック
- 04 残り時間バー
- 05 元に戻すトースト
- 06 スワイプで閉じる
- 07 成功アイコン
- 08 モバイル下部トースト
- 09 通知バッジ
9つは目立たせ方の順ではなく、画面に現れてから消えるまでの流れで並べました。登場の型を変えた3つ(01〜03)、閉じ方や消えるきっかけを変えた3つ(04〜06)、状態や環境に応じて見た目を変える3つ(07〜09)という並びです。同じ開閉の文法をナビへ付ければCSSドロップダウンメニューになり、画面の端から押し出す板まで広げればサイドバードロワーになります。
01右からスライドイン
画面右外から滑り込む
カードは画面右外のtranslateX(140%)から0まで滑り込み、そのまま少し止まってから同じ方向へ抜けていきます。実際の表示は0.6秒で、色を示す左側のバーだけは別要素として重ねています。保存やアップロードが終わったことを画面の隅で知らせる、デスクトップ向けの基本形です。

<main class="stage">
<div class="toast is-demo" role="status" aria-live="polite">
<span class="toast__bar" aria-hidden="true"></span>
<div class="toast__body">
<p class="toast__title">保存しました</p>
<p class="toast__text">新しい項目を保存しました</p>
</div>
<button class="toast__close" type="button" aria-label="通知を閉じる">
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<line x1="5" y1="5" x2="19" y2="19" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/>
<line x1="19" y1="5" x2="5" y2="19" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/>
</svg>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-enter; // 600ms — 오른쪽에서 미끄러져 들어오는 시간
$easing: $ease-spring;
$color: $subject-blue; // 왼쪽 강조 막대 색
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
// 실측 2026-09-10: 토스트가 오른쪽 밖(translateX 140%)에서 들어온다 — html/body 의 overflow:hidden 은
// 뷰포트로 전파돼 scrollWidth 를 못 줄인다(320px 넘침 재측정 실측). .stage 자체를 클리핑 컨테이너로 만든다.
.stage { overflow: hidden; }
.toast {
position: relative;
box-sizing: border-box;
display: flex; align-items: flex-start; gap: $sp-3;
width: min(280px, 86vw);
padding: $sp-4 $sp-10 $sp-4 $sp-4;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
transform: translateX(140%);
opacity: 0;
}
.toast__bar {
align-self: stretch;
width: $sp-1;
border-radius: $r-xs;
background: $color;
flex: none;
}
.toast__title { margin: 0 0 $sp-1; font-weight: 700; font-size: 15px; line-height: 1.3; font-family: inherit; color: $subject-ink; }
.toast__text { margin: 0; font-weight: 400; font-size: 13px; line-height: 1.4; font-family: inherit; color: $subject-ink; opacity: .78; }
.toast__close {
position: absolute; top: $sp-2; right: $sp-2;
display: grid; place-items: center;
width: $sp-6; height: $sp-6;
border: 0; border-radius: $r-xs;
background: transparent; color: $subject-ink; opacity: .55;
cursor: pointer;
&:hover, &:focus-visible { opacity: 1; }
}
// 실측 2026-09-10: 오른쪽 밖에서 들어와 머물다 다시 나간다. 한 바퀴 = 전역 루프(2s)
.toast.is-demo { animation: slide-loop $dur-loop $easing infinite; }
@keyframes slide-loop {
0% { transform: translateX(140%); opacity: 0; }
18% { transform: translateX(0); opacity: 1; }
78% { transform: translateX(0); opacity: 1; }
100% { transform: translateX(140%); opacity: 0; }
}
// ── 접근성: 움직임 줄이기 설정이면 자리만, 슬라이드는 생략 ────────
@media (prefers-reduced-motion: reduce) {
.toast.is-demo { animation: none; transform: translateX(0); opacity: 1; }
}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);
}
.stage {
overflow: hidden;
}
.toast {
position: relative;
box-sizing: border-box;
display: flex;
align-items: flex-start;
gap: 12px;
width: min(280px, 86vw);
padding: 16px 40px 16px 16px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translateX(140%);
opacity: 0;
}
.toast__bar {
align-self: stretch;
width: 4px;
border-radius: 4px;
background: #2f6df6;
flex: none;
}
.toast__title {
margin: 0 0 4px;
font-weight: 700;
font-size: 15px;
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.toast__text {
margin: 0;
font-weight: 400;
font-size: 13px;
line-height: 1.4;
font-family: inherit;
color: #17141a;
opacity: 0.78;
}
.toast__close {
position: absolute;
top: 8px;
right: 8px;
display: grid;
place-items: center;
width: 24px;
height: 24px;
border: 0;
border-radius: 4px;
background: transparent;
color: #17141a;
opacity: 0.55;
cursor: pointer;
}
.toast__close:hover, .toast__close:focus-visible {
opacity: 1;
}
.toast.is-demo {
animation: slide-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes slide-loop {
0% {
transform: translateX(140%);
opacity: 0;
}
18% {
transform: translateX(0);
opacity: 1;
}
78% {
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(140%);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.toast.is-demo {
animation: none;
transform: translateX(0);
opacity: 1;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./ToastSlideIn.module.scss";
export type ToastSlideInProps = {
visible: boolean;
title: string;
children: ReactNode;
onClose?: () => void;
/** 오른쪽에서 미끄러져 들어오는 시간 (기본 600ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ToastSlideIn({
visible,
title,
children,
onClose,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ToastSlideInProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.toast} ${visible ? styles.isVisible : ""}`} style={vars} role="status" aria-live="polite">
<span className={styles.bar} aria-hidden="true" />
<div className={styles.body}>
<p className={styles.title}>{title}</p>
<p className={styles.text}>{children}</p>
</div>
<button className={styles.close} type="button" aria-label="알림 닫기" onClick={onClose}>
<svg viewBox="0 0 24 24" width={14} height={14} aria-hidden="true">
<line x1={5} y1={5} x2={19} y2={19} stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" />
<line x1={19} y1={5} x2={5} y2={19} stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" />
</svg>
</button>
</div>
);
}01 · 右からスライドイン — 計測 累積面積 19.466% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
デスクトップ向けの基本形
.toast {
transform: translateX(140%);
opacity: 0;
}
.toast.is-demo { animation: slide-loop $dur-loop $easing infinite; }
@keyframes slide-loop {
0% { transform: translateX(140%); opacity: 0; }
18% { transform: translateX(0); opacity: 1; }
78% { transform: translateX(0); opacity: 1; }
100% { transform: translateX(140%); opacity: 0; }
}
02Popover APIトースト
「css トースト」をJSなしで開閉する
popover属性とpopovertargetボタンだけで開閉し、ESCや外側クリックでの終了もJSなしで手に入ります。既定では画面中央に固定されるUAスタイルをposition: fixedで上書きし、右上の角に表示させています。ネットワーク状態のようなシステム通知や、軽量なウィジェットに向いています。

<main class="stage">
<div class="demo">
<button class="opener" type="button" popovertarget="ptoast048">通知をもう一度見る</button>
<div id="ptoast048" class="toast is-demo" popover role="status" aria-live="polite">
<span class="toast__dot" aria-hidden="true"></span>
<div class="toast__body">
<p class="toast__title">接続済み</p>
<p class="toast__text">ネットワークが復旧しました</p>
</div>
<button class="toast__close" type="button" popovertarget="ptoast048" popovertargetaction="hide" aria-label="通知を閉じる">
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<line x1="5" y1="5" x2="19" y2="19" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/>
<line x1="19" y1="5" x2="5" y2="19" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/>
</svg>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base; // 300ms — 열리고 닫히는 속도
$easing: $ease-out;
$color: $subject-blue; // 상태 점 색
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.demo { display: grid; justify-items: center; gap: $sp-4; }
.opener {
border: 0; background: transparent;
color: $subject-ink; font-weight: 600; font-size: 13px; line-height: 1; font-family: inherit;
text-decoration: underline; text-underline-offset: $sp-1;
padding: $sp-2 $sp-1; cursor: pointer;
}
// popover 기본 스타일(테두리·배경·중앙 고정)을 지우고 화면 오른쪽 위 코너로
.toast[popover] {
position: fixed;
inset: unset;
top: $sp-4;
right: $sp-4;
margin: 0;
border: 0;
overflow: visible;
display: flex; align-items: flex-start; gap: $sp-3;
width: min(260px, 78vw);
padding: $sp-4 $sp-10 $sp-4 $sp-4;
border-radius: $r-card;
background: $subject-ink;
color: $subject-cream;
box-shadow: $shadow-raised;
}
.toast__dot {
align-self: center;
width: $sp-3; height: $sp-3;
border-radius: 50%;
background: $color;
flex: none;
}
.toast__title { margin: 0 0 $sp-1; font-weight: 700; font-size: 14px; line-height: 1.3; font-family: inherit; }
.toast__text { margin: 0; font-weight: 400; font-size: 13px; line-height: 1.4; font-family: inherit; opacity: .78; }
.toast__close {
position: absolute; top: $sp-2; right: $sp-2;
display: grid; place-items: center;
width: $sp-6; height: $sp-6;
border: 0; border-radius: $r-xs;
background: transparent; color: $subject-cream; opacity: .6;
cursor: pointer;
&:hover, &:focus-visible { opacity: 1; }
}
// 실측 2026-09-10: showPopover() 로 이미 열린 상태에서 등장→유지→살짝 접히는 루프를 계속 반복해
// "3초 뒤 자동으로 닫힌다"는 실제 동작을 무한 루프로 대신 보여준다 (전역 루프 2s)
.toast.is-demo { animation: pop-loop $dur-loop $easing infinite; }
@keyframes pop-loop {
0% { transform: translateY(-14px) scale(.92); opacity: 0; }
16% { transform: translateY(0) scale(1); opacity: 1; }
72% { transform: translateY(0) scale(1); opacity: 1; }
92% { transform: translateY(-6px) scale(.97); opacity: 0; }
100% { transform: translateY(-14px) scale(.92); opacity: 0; }
}
// ── 접근성: 움직임 줄이기 설정이면 뜬 채로 고정 ────────
@media (prefers-reduced-motion: reduce) {
.toast.is-demo { animation: none; transform: none; 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);
}
.demo {
display: grid;
justify-items: center;
gap: 16px;
}
.opener {
border: 0;
background: transparent;
color: #17141a;
font-weight: 600;
font-size: 13px;
line-height: 1;
font-family: inherit;
text-decoration: underline;
text-underline-offset: 4px;
padding: 8px 4px;
cursor: pointer;
}
.toast[popover] {
position: fixed;
inset: unset;
top: 16px;
right: 16px;
margin: 0;
border: 0;
overflow: visible;
display: flex;
align-items: flex-start;
gap: 12px;
width: min(260px, 78vw);
padding: 16px 40px 16px 16px;
border-radius: 16px;
background: #17141a;
color: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.toast__dot {
align-self: center;
width: 12px;
height: 12px;
border-radius: 50%;
background: #2f6df6;
flex: none;
}
.toast__title {
margin: 0 0 4px;
font-weight: 700;
font-size: 14px;
line-height: 1.3;
font-family: inherit;
}
.toast__text {
margin: 0;
font-weight: 400;
font-size: 13px;
line-height: 1.4;
font-family: inherit;
opacity: 0.78;
}
.toast__close {
position: absolute;
top: 8px;
right: 8px;
display: grid;
place-items: center;
width: 24px;
height: 24px;
border: 0;
border-radius: 4px;
background: transparent;
color: #fff7e6;
opacity: 0.6;
cursor: pointer;
}
.toast__close:hover, .toast__close:focus-visible {
opacity: 1;
}
.toast.is-demo {
animation: pop-loop 2s ease-out infinite;
}
@keyframes pop-loop {
0% {
transform: translateY(-14px) scale(0.92);
opacity: 0;
}
16% {
transform: translateY(0) scale(1);
opacity: 1;
}
72% {
transform: translateY(0) scale(1);
opacity: 1;
}
92% {
transform: translateY(-6px) scale(0.97);
opacity: 0;
}
100% {
transform: translateY(-14px) scale(0.92);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.toast.is-demo {
animation: none;
transform: none;
opacity: 1;
}
}// popover 속성이 top-layer 스택·ESC 닫기·바깥 클릭 닫기를 공짜로 준다. 데모용으로 로드 즉시 한 번 연다.
document.getElementById('ptoast048').showPopover();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./PopoverToast.module.scss";
export type PopoverToastProps = {
title: string;
message: string;
/** true 면 showPopover(), false 면 hidePopover() (기본 true) */
open?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PopoverToast({
title,
message,
open = true,
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: PopoverToastProps) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = ref.current as (HTMLDivElement & { showPopover?: () => void; hidePopover?: () => void }) | null;
if (!el) return;
if (open) el.showPopover?.();
else el.hidePopover?.();
}, [open]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div ref={ref} popover="" className={styles.toast} style={vars} role="status" aria-live="polite">
<span className={styles.dot} aria-hidden="true" />
<div className={styles.body}>
<p className={styles.title}>{title}</p>
<p className={styles.text}>{message}</p>
</div>
</div>
);
}02 · Popover APIトースト — 計測 累積面積 23.62% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
システム通知や軽量ウィジェットに
<div id="toast" class="toast" popover role="status" aria-live="polite">
<span class="toast__dot" aria-hidden="true"></span>
<p class="toast__title">接続済み</p>
<button popovertarget="toast" popovertargetaction="hide" aria-label="通知を閉じる">
<svg viewBox="0 0 24 24" width="14" height="14">
<line x1="5" y1="5" x2="19" y2="19"/>
<line x1="19" y1="5" x2="5" y2="19"/>
</svg>
</button>
</div>
<script>document.getElementById('toast').showPopover();</script>
033件スタック
インラインの--iでずらす
3枚のカードにはインラインの--i(0・1・2)が振られていて、普段はtranslateY(calc(var(--i)*-6px))とscaleでわずかにずれて重なっています。hoverまたはfocus-withinで-22px・scale(1)まで開き、3件がそれぞれ読めるようになります。短時間にイベントが重なるダッシュボードの通知トレイに向いています。

<main class="stage">
<div class="stack is-demo" tabindex="0" role="status" aria-live="polite" aria-label="通知3件">
<div class="stack__item" style="--i:2">ポイントが加算されました</div>
<div class="stack__item" style="--i:1">発送が開始されました</div>
<div class="stack__item" style="--i:0">決済が完了しました</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base; // 300ms — 실제 hover/focus 로 펼쳐지는 속도
$easing: $ease-spring;
$color: $subject-cream; // 카드 색
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.stack {
position: relative;
width: min(260px, 78vw);
height: 92px;
outline: none;
}
.stack__item {
position: absolute; inset: 0;
display: flex; align-items: center;
padding: $sp-4;
border-radius: $r-card;
background: $color;
color: $subject-ink;
font-weight: 600;
font-size: 13px;
line-height: 1.4;
font-family: inherit;
box-shadow: $shadow-press;
// 뒤 카드일수록(--i 클수록) 더 밀리고 작아지고 흐려진다 — 평소엔 살짝 겹친 "덱" 모양
transform: translateY(calc(var(--i) * -6px)) scale(calc(1 - var(--i) * .05));
opacity: calc(1 - var(--i) * .22);
}
// 실제 사용: hover·focus 로 카드 3장을 부채꼴로 펼친다 (JS 없이)
.stack:hover .stack__item,
.stack:focus-within .stack__item {
transform: translateY(calc(var(--i) * -22px)) scale(1);
opacity: 1;
transition: transform $duration $easing, opacity $duration $easing;
}
// 실측 2026-09-10: 018 편의 "토스트 쌓임"(새 토스트가 올수록 기존 걸 밀어 올리는 큐)과 다르다 —
// 여기는 항상 3장이 고정으로 있고, 접힌 덱이 펼쳐졌다 다시 접히는 "카드 뭉치" 자체가 하나의 애니메이션이다.
.stack.is-demo .stack__item { animation: spread $dur-loop $easing infinite; }
@keyframes spread {
0%, 100% {
transform: translateY(calc(var(--i) * -6px)) scale(calc(1 - var(--i) * .05));
opacity: calc(1 - var(--i) * .22);
}
35%, 65% {
transform: translateY(calc(var(--i) * -22px)) scale(1);
opacity: 1;
}
}
// ── 접근성: 움직임 줄이기 설정이면 펼쳐진 채로 고정 ────────
@media (prefers-reduced-motion: reduce) {
.stack.is-demo .stack__item { animation: none; transform: translateY(calc(var(--i) * -22px)) scale(1); opacity: 1; }
}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);
}
.stack {
position: relative;
width: min(260px, 78vw);
height: 92px;
outline: none;
}
.stack__item {
position: absolute;
inset: 0;
display: flex;
align-items: center;
padding: 16px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-weight: 600;
font-size: 13px;
line-height: 1.4;
font-family: inherit;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateY(calc(var(--i) * -6px)) scale(calc(1 - var(--i) * 0.05));
opacity: calc(1 - var(--i) * 0.22);
}
.stack:hover .stack__item,
.stack:focus-within .stack__item {
transform: translateY(calc(var(--i) * -22px)) scale(1);
opacity: 1;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.stack.is-demo .stack__item {
animation: spread 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes spread {
0%, 100% {
transform: translateY(calc(var(--i) * -6px)) scale(calc(1 - var(--i) * 0.05));
opacity: calc(1 - var(--i) * 0.22);
}
35%, 65% {
transform: translateY(calc(var(--i) * -22px)) scale(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.stack.is-demo .stack__item {
animation: none;
transform: translateY(calc(var(--i) * -22px)) scale(1);
opacity: 1;
}
}import type { CSSProperties } from "react";
import styles from "./ToastStack3.module.scss";
export type ToastStack3Props = {
/** 앞(맨 위)부터 순서대로 3개 */
items: [string, string, string];
color?: string;
duration?: number;
easing?: string;
};
type ItemVars = CSSProperties & { "--i"?: number };
export default function ToastStack3({
items,
color = "#fff7e6",
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
}: ToastStack3Props) {
const vars = { "--color": color, "--duration": `${duration}ms`, "--easing": easing } as CSSProperties;
const withIndex = items.map((text, i) => ({ text, i }));
const backToFront = [...withIndex].reverse(); // DOM은 뒤(i 큰 값)부터 써야 앞(i=0)이 맨 위에 그려진다
return (
<div
className={styles.stack}
style={vars}
tabIndex={0}
role="status"
aria-live="polite"
aria-label={`알림 ${items.length}개`}
>
{backToFront.map(({ text, i }) => {
const itemVars: ItemVars = { "--i": i };
return (
<div key={i} className={styles.item} style={itemVars}>
{text}
</div>
);
})}
</div>
);
}03 · 3件スタック — 計測 累積面積 9.192% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ダッシュボードの通知トレイに
.stack__item {
position: absolute; inset: 0;
transform: translateY(calc(var(--i) * -6px))
scale(calc(1 - var(--i) * .05));
opacity: calc(1 - var(--i) * .22);
}
.stack:hover .stack__item,
.stack:focus-within .stack__item {
transform: translateY(calc(var(--i) * -22px)) scale(1);
opacity: 1;
transition: transform $duration $easing, opacity $duration $easing;
}
04残り時間バー
等速で縮むバーと同じタイミング
下端の進行バーはscaleX(1)からscaleX(0)へ等速で縮み、なくなる瞬間と同じタイミングでトースト本体がopacityとtranslateY(-8px)で消えます。実際の表示は5秒で、進行バーのanimationendをきっかけにJSがトーストを取り除きます。アップロードや送信の完了のように、決まった時間で自動的に閉じる通知に使います。

<main class="stage">
<div class="toast is-demo" role="status" aria-live="polite">
<div class="toast__body">
<p class="toast__title">アップロード完了</p>
<p class="toast__text">5秒後に自動的に閉じます</p>
</div>
<span class="toast__bar" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: 5000ms; // 진행바가 다 닳아 닫히기까지 실제 시간 (UI 모션이 아니라 타이머 값이라 토큰 밖)
$easing: linear; // 진행바는 등속으로 — 급가속하면 "얼마 남았는지" 신뢰가 깨진다
$color: $subject-blue; // 진행바 색
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.toast {
position: relative;
overflow: hidden;
width: min(280px, 84vw);
padding: $sp-4;
border-radius: $r-card;
background: $subject-ink;
color: $subject-cream;
box-shadow: $shadow-raised;
}
.toast__title { margin: 0 0 $sp-1; font-weight: 700; font-size: 15px; line-height: 1.3; font-family: inherit; }
.toast__text { margin: 0; font-weight: 400; font-size: 13px; line-height: 1.4; font-family: inherit; opacity: .78; }
.toast__bar {
position: absolute; left: 0; bottom: 0;
width: 100%; height: $sp-1;
background: $color;
transform-origin: left; // 규칙8: 진행바 왼쪽 끝 고정
transform: scaleX(1);
}
// 실제 사용: 진행바의 animationend 가 곧 닫힘 신호다. bar.addEventListener('animationend', () => toast.remove())
// 데모는 이 사이클(진행바가 다 닳고 → 토스트가 사라짐)을 전역 루프(2s) 안에 압축해 반복한다
.toast.is-demo .toast__bar { animation: bar-shrink $dur-loop $easing infinite; }
.toast.is-demo { animation: toast-out $dur-loop $ease-out infinite; }
@keyframes bar-shrink {
0% { transform: scaleX(1); }
82% { transform: scaleX(0); }
100% { transform: scaleX(0); }
}
@keyframes toast-out {
0%, 82% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-8px); }
}
// ── 접근성: 움직임 줄이기 설정이면 진행바만 조용히 줄고 토스트는 안 흔들린다 ────────
@media (prefers-reduced-motion: reduce) {
.toast.is-demo { animation: none; }
.toast.is-demo .toast__bar { animation: bar-shrink $duration linear forwards; }
}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);
}
.toast {
position: relative;
overflow: hidden;
width: min(280px, 84vw);
padding: 16px;
border-radius: 16px;
background: #17141a;
color: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.toast__title {
margin: 0 0 4px;
font-weight: 700;
font-size: 15px;
line-height: 1.3;
font-family: inherit;
}
.toast__text {
margin: 0;
font-weight: 400;
font-size: 13px;
line-height: 1.4;
font-family: inherit;
opacity: 0.78;
}
.toast__bar {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 4px;
background: #2f6df6;
transform-origin: left;
transform: scaleX(1);
}
.toast.is-demo .toast__bar {
animation: bar-shrink 2s linear infinite;
}
.toast.is-demo {
animation: toast-out 2s ease-out infinite;
}
@keyframes bar-shrink {
0% {
transform: scaleX(1);
}
82% {
transform: scaleX(0);
}
100% {
transform: scaleX(0);
}
}
@keyframes toast-out {
0%, 82% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-8px);
}
}
@media (prefers-reduced-motion: reduce) {
.toast.is-demo {
animation: none;
}
.toast.is-demo .toast__bar {
animation: bar-shrink 5000ms linear forwards;
}
}import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./ProgressCloseToast.module.scss";
export type ProgressCloseToastProps = {
title: string;
message: string;
onDismiss?: () => void;
/** 진행바가 다 닳기까지 실제 시간 (기본 5000ms) */
duration?: number;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--color"?: string };
export default function ProgressCloseToast({
title,
message,
onDismiss,
duration = 5000,
color = "#2f6df6",
}: ProgressCloseToastProps) {
const barRef = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
const bar = barRef.current;
if (!bar || !onDismiss) return;
const onEnd = () => onDismiss();
bar.addEventListener("animationend", onEnd);
return () => bar.removeEventListener("animationend", onEnd);
}, [onDismiss]);
const vars: Vars = { "--duration": `${duration}ms`, "--color": color };
return (
<div className={styles.toast} style={vars} role="status" aria-live="polite">
<div className={styles.body}>
<p className={styles.title}>{title}</p>
<p className={styles.text}>{message}</p>
</div>
<span ref={barRef} className={styles.bar} aria-hidden="true" />
</div>
);
}04 · 残り時間バー — 計測 累積面積 23.668% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
決まった時間で閉じる通知に
.toast__bar {
position: absolute; left: 0; bottom: 0;
width: 100%; height: 4px;
background: $color;
transform-origin: left;
}
.toast.is-demo .toast__bar { animation: bar-shrink $dur-loop linear infinite; }
@keyframes bar-shrink {
0% { transform: scaleX(1); }
82% { transform: scaleX(0); }
100% { transform: scaleX(0); }
}
05元に戻すトースト
clearTimeoutでタイマーを止める
削除メッセージ横の「元に戻す」ボタンを押すとJSのclearTimeoutでタイマーが止まり、文言が取り消し済みに変わります。押さないまま4秒経つとtransform-origin: topからscaleY(1)が0まで縮み、高さそのものは動かさずに畳まれて消えます。確認ダイアログの代わりに、リストやメール削除の取り消し操作に使います。

<main class="stage">
<div class="toast is-demo" role="status" aria-live="polite">
<p class="toast__text">項目を削除しました</p>
<button class="toast__undo" type="button">元に戻す</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base; // 300ms — 접히며 사라지는 시간
$easing: $ease-out;
$color: $subject-blue-deep; // 실행 취소 글자 색
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.toast {
box-sizing: border-box;
display: flex; align-items: center; justify-content: space-between; gap: $sp-4;
width: min(300px, 88vw);
padding: $sp-4;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
box-shadow: $shadow-raised;
transform-origin: top center; // 규칙8: 위 변에서 접혀 사라짐
}
.toast__text { margin: 0; font-weight: 600; font-size: 13px; line-height: 1.4; font-family: inherit; }
.toast__undo {
flex: none;
border: 0; background: transparent;
color: $color; font-weight: 700; font-size: 13px; line-height: 1; font-family: inherit;
padding: $sp-2 $sp-1;
cursor: pointer;
&:hover, &:focus-visible { text-decoration: underline; }
}
.toast.is-cancelled .toast__undo { visibility: hidden; }
// 실측 2026-09-10: 05번은 "실행취소" 버튼이 본체다. 데모는 등장 → 유지 → (취소 없이) 위에서부터 접히며 사라지는
// 한 바퀴를 반복한다 — scaleY 로 접는다(height 는 애니메이션하지 않는다, 원칙 1)
.toast.is-demo {
animation: undo-loop $dur-loop $easing infinite;
}
@keyframes undo-loop {
0% { transform: translateY(16px) scaleY(1); opacity: 0; }
15% { transform: translateY(0) scaleY(1); opacity: 1; }
70% { transform: translateY(0) scaleY(1); opacity: 1; }
92% { transform: translateY(0) scaleY(.35); opacity: .3; }
100% { transform: translateY(0) scaleY(0); opacity: 0; }
}
// ── 접근성: 움직임 줄이기 설정이면 접히지 않고 보이다 사라진다(opacity만) ────────
@media (prefers-reduced-motion: reduce) {
.toast.is-demo { animation: none; transform: none; opacity: 1; }
}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);
}
.toast {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: min(300px, 88vw);
padding: 16px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: top center;
}
.toast__text {
margin: 0;
font-weight: 600;
font-size: 13px;
line-height: 1.4;
font-family: inherit;
}
.toast__undo {
flex: none;
border: 0;
background: transparent;
color: #0a4fe8;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
padding: 8px 4px;
cursor: pointer;
}
.toast__undo:hover, .toast__undo:focus-visible {
text-decoration: underline;
}
.toast.is-cancelled .toast__undo {
visibility: hidden;
}
.toast.is-demo {
animation: undo-loop 2s ease-out infinite;
}
@keyframes undo-loop {
0% {
transform: translateY(16px) scaleY(1);
opacity: 0;
}
15% {
transform: translateY(0) scaleY(1);
opacity: 1;
}
70% {
transform: translateY(0) scaleY(1);
opacity: 1;
}
92% {
transform: translateY(0) scaleY(0.35);
opacity: 0.3;
}
100% {
transform: translateY(0) scaleY(0);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.toast.is-demo {
animation: none;
transform: none;
opacity: 1;
}
}// 실제 동작: 4초 뒤 사라지지만, 그 전에 "元に戻す"를 누르면 타이머를 멈추고 문구를 바꾼다
document.querySelectorAll('.toast').forEach(function (t) {
var btn = t.querySelector('.toast__undo'), msg = t.querySelector('.toast__text');
var timer = setTimeout(function () { t.classList.add('is-gone'); }, 4000);
btn.addEventListener('click', function () {
clearTimeout(timer);
msg.textContent = '取り消しました';
t.classList.add('is-cancelled');
});
});import { useCallback, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./UndoToast.module.scss";
export type UndoToastProps = {
message: string;
undoLabel?: string;
cancelledMessage?: string;
onExpire?: () => void;
/** 몇 ms 뒤 자동으로 onExpire 를 부를지 (기본 4000ms) */
timeout?: number;
color?: string;
};
type Vars = CSSProperties & { "--color"?: string };
export default function UndoToast({
message,
undoLabel = "실행 취소",
cancelledMessage = "취소되었습니다",
onExpire,
timeout = 4000,
color = "#0a4fe8",
}: UndoToastProps) {
const [cancelled, setCancelled] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
if (timer.current === null && onExpire) {
timer.current = setTimeout(onExpire, timeout);
}
const undo = useCallback(() => {
if (timer.current) clearTimeout(timer.current);
setCancelled(true);
}, []);
const vars: Vars = { "--color": color };
return (
<div className={styles.toast} style={vars} role="status" aria-live="polite">
<p className={styles.text}>{cancelled ? cancelledMessage : message}</p>
{!cancelled && (
<button className={styles.undo} type="button" onClick={undo}>
{undoLabel}
</button>
)}
</div>
);
}05 · 元に戻すトースト — 計測 累積面積 14.713% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
削除の取り消し操作に
document.querySelectorAll('.toast').forEach(function (t) {
var btn = t.querySelector('.toast__undo');
var msg = t.querySelector('.toast__text');
var timer = setTimeout(function () {
t.classList.add('is-gone');
}, 4000);
btn.addEventListener('click', function () {
clearTimeout(timer);
msg.textContent = '取り消しました';
t.classList.add('is-cancelled');
});
});
06スワイプで閉じる
--dxに移動距離を書き込む
ポインターを押したまま動かすとpointermoveが移動距離を--dxに書き込み、translateX(var(--dx))でカードがそのまま追従します。指を離した位置が80pxを超えていればその方向へ滑り出て閉じ、超えなければ0へ戻ります。touch-action: pan-yで縦スクロールを残しているので、モバイルのチャット通知に向いています。

<main class="stage">
<div class="toast is-demo" role="status" aria-live="polite">
<p class="toast__text">新着メッセージが届きました</p>
<span class="toast__hint" aria-hidden="true">スワイプして閉じる</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick; // 220ms — 놓쳤을 때 닫히는 속도
$easing: $ease-out;
$color: $subject-ink; // 카드 색
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.toast {
position: relative;
display: flex; align-items: center; justify-content: space-between; gap: $sp-3;
width: min(280px, 84vw);
padding: $sp-4;
border-radius: $r-card;
background: $color;
color: $subject-cream;
box-shadow: $shadow-raised;
touch-action: pan-y; // 세로 스크롤은 살리고 가로만 드래그로 가져간다
transform: translateX(var(--dx, 0px));
}
.toast__text { margin: 0; font-weight: 600; font-size: 13px; line-height: 1.4; font-family: inherit; }
.toast__hint { flex: none; font-weight: 400; font-size: 11px; line-height: 1; font-family: inherit; opacity: .55; }
.toast.is-swiped-left,
.toast.is-swiped-right {
transition: transform $duration $easing, opacity $duration $easing;
opacity: 0;
}
.toast.is-swiped-left { transform: translateX(-120%); }
.toast.is-swiped-right { transform: translateX(120%); }
// 실측 2026-09-10: 손이 없는 렌더에서도 "밀려서 사라짐"이 보이게 자동 루프를 별도로 둔다
.toast.is-demo { animation: swipe-loop $dur-loop $easing infinite; }
@keyframes swipe-loop {
0% { transform: translateX(0); opacity: 0; }
10% { transform: translateX(0); opacity: 1; }
55% { transform: translateX(0); opacity: 1; }
85% { transform: translateX(120%); opacity: 0; }
86% { transform: translateX(0); opacity: 0; }
100% { transform: translateX(0); opacity: 0; }
}
// ── 접근성: 움직임 줄이기 설정이면 드래그 반응만 남기고 자동 루프는 끈다 ────────
@media (prefers-reduced-motion: reduce) {
.toast.is-demo { animation: none; opacity: 1; transform: translateX(0); }
.toast.is-swiped-left, .toast.is-swiped-right { transition: opacity $duration linear; }
}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);
}
.toast {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: min(280px, 84vw);
padding: 16px;
border-radius: 16px;
background: #17141a;
color: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
touch-action: pan-y;
transform: translateX(var(--dx, 0px));
}
.toast__text {
margin: 0;
font-weight: 600;
font-size: 13px;
line-height: 1.4;
font-family: inherit;
}
.toast__hint {
flex: none;
font-weight: 400;
font-size: 11px;
line-height: 1;
font-family: inherit;
opacity: 0.55;
}
.toast.is-swiped-left,
.toast.is-swiped-right {
transition: transform 220ms ease-out, opacity 220ms ease-out;
opacity: 0;
}
.toast.is-swiped-left {
transform: translateX(-120%);
}
.toast.is-swiped-right {
transform: translateX(120%);
}
.toast.is-demo {
animation: swipe-loop 2s ease-out infinite;
}
@keyframes swipe-loop {
0% {
transform: translateX(0);
opacity: 0;
}
10% {
transform: translateX(0);
opacity: 1;
}
55% {
transform: translateX(0);
opacity: 1;
}
85% {
transform: translateX(120%);
opacity: 0;
}
86% {
transform: translateX(0);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.toast.is-demo {
animation: none;
opacity: 1;
transform: translateX(0);
}
.toast.is-swiped-left, .toast.is-swiped-right {
transition: opacity 220ms linear;
}
}// 실제 동작: 손가락(또는 마우스)을 뗀 순간 80px 이상 밀렸으면 그 방향으로 닫힌다. 데모 루프는 놓고 자동 재생
(function () {
var el = document.querySelector('.toast'), x0 = 0, dx = 0, dragging = false;
el.addEventListener('pointerdown', function (e) { x0 = e.clientX; dragging = true; el.setPointerCapture(e.pointerId); el.classList.remove('is-demo'); });
el.addEventListener('pointermove', function (e) { if (!dragging) return; dx = e.clientX - x0; el.style.setProperty('--dx', dx + 'px'); });
el.addEventListener('pointerup', function () {
dragging = false;
if (Math.abs(dx) > 80) el.classList.add(dx > 0 ? 'is-swiped-right' : 'is-swiped-left');
else el.style.setProperty('--dx', '0px');
dx = 0;
});
})();import { useCallback, useRef, useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./SwipeDismissToast.module.scss";
export type SwipeDismissToastProps = {
message: string;
hint?: string;
onDismiss?: (direction: "left" | "right") => void;
/** 이 px 이상 밀면 닫힌다 (기본 80) */
threshold?: number;
duration?: number;
color?: string;
};
type Vars = CSSProperties & { "--dx"?: string; "--color"?: string };
export default function SwipeDismissToast({
message,
hint = "밀어서 닫기",
onDismiss,
threshold = 80,
duration = 220,
color = "#17141a",
}: SwipeDismissToastProps) {
const [dx, setDx] = useState(0);
const [swiped, setSwiped] = useState<"left" | "right" | null>(null);
const x0 = useRef(0);
const dragging = useRef(false);
const onDown = useCallback((e: PointerEvent<HTMLDivElement>) => {
x0.current = e.clientX;
dragging.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
}, []);
const onMove = useCallback((e: PointerEvent<HTMLDivElement>) => {
if (!dragging.current) return;
setDx(e.clientX - (x0.current ?? 0));
}, []);
const onUp = useCallback(() => {
dragging.current = false;
if (Math.abs(dx) > threshold) {
const dir = dx > 0 ? "right" : "left";
setSwiped(dir);
onDismiss?.(dir);
} else {
setDx(0);
}
}, [dx, threshold, onDismiss]);
const vars: Vars = { "--dx": `${swiped ? 0 : dx}px`, "--color": color };
return (
<div
className={`${styles.toast} ${swiped ? styles[swiped === "left" ? "swipedLeft" : "swipedRight"] : ""}`}
style={vars}
role="status"
aria-live="polite"
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
>
<p className={styles.text}>{message}</p>
<span className={styles.hint} aria-hidden="true">{hint}</span>
</div>
);
}06 · スワイプで閉じる — 計測 累積面積 20.664% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
モバイルのチャット通知に
el.addEventListener('pointerdown', function (e) {
x0 = e.clientX;
dragging = true;
el.setPointerCapture(e.pointerId);
});
el.addEventListener('pointermove', function (e) {
if (!dragging) return;
dx = e.clientX - x0;
el.style.setProperty('--dx', dx + 'px');
});
el.addEventListener('pointerup', function () {
dragging = false;
if (Math.abs(dx) > 80) {
el.classList.add(dx > 0 ? 'is-swiped-right' : 'is-swiped-left');
} else {
el.style.setProperty('--dx', '0px');
}
});
07成功アイコン
2枚のSVGをクロスフェード
無彩色のリングと状態色のリング+チェックという2枚のSVGをgrid-area: 1 / 1で重ね、opacityのクロスフェードで入れ替えます。チェック線は同時にstroke-dashoffsetが100から0へ動いて描かれます。$colorをエラー色に替えるだけで、そのまま失敗バッジになります。
<main class="stage">
<div class="toast is-demo" role="status" aria-live="polite">
<span class="badge" aria-hidden="true">
<svg class="badge__layer badge__neutral" viewBox="0 0 24 24" width="28" height="28">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
<svg class="badge__layer badge__colored" viewBox="0 0 24 24" width="28" height="28">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline class="badge__check" points="7 12.5 10.5 16 17 8.5" pathLength="100" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<div class="toast__body">
<p class="toast__title">決済完了</p>
<p class="toast__text">カード決済が承認されました</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. $color 를 $error 로 바꾸면 "실패" 상태 아이콘이 된다 ──────────
$duration: $dur-base; // 300ms — 체크가 그려지는 시간대
$easing: $ease-out;
$color: $subject-blue; // 상태색. 성공은 blue, 변형에서 error 로
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.toast {
display: flex; align-items: center; gap: $sp-3;
width: min(280px, 84vw);
padding: $sp-4;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
box-shadow: $shadow-raised;
}
.badge { position: relative; display: grid; place-items: center; width: 28px; height: 28px; flex: none; }
.badge__layer { grid-area: 1 / 1; }
.badge__neutral { color: $subject-ink; opacity: .3; }
.badge__colored { color: $color; opacity: 0; }
.badge__check { stroke-dasharray: 100; stroke-dashoffset: 100; }
.toast__title { margin: 0 0 $sp-1; font-weight: 700; font-size: 15px; line-height: 1.3; font-family: inherit; }
.toast__text { margin: 0; font-weight: 400; font-size: 13px; line-height: 1.4; font-family: inherit; opacity: .78; }
// 실측 2026-09-10: 018 편 "저장 체크"(디스켓→체크 clip-path 모프)와도, 031 편 "읽음 표시"(숫자→체크 opacity·scale)와도 다르다.
// 여기는 두 아이콘 레이어(무채색 링 / 상태색 링+체크)가 opacity 로 겹쳐 바뀌면서 "상태색이 곧 결과"라는 걸 보여준다.
.toast.is-demo {
.badge__neutral { animation: fade-out $dur-loop $easing infinite; }
.badge__colored { animation: fade-in $dur-loop $easing infinite; }
.badge__check { animation: draw $dur-loop $easing infinite; }
}
@keyframes fade-out {
0%, 15% { opacity: .3; }
32%, 80% { opacity: 0; }
95%, 100% { opacity: .3; }
}
@keyframes fade-in {
0%, 15% { opacity: 0; }
32%, 80% { opacity: 1; }
95%, 100% { opacity: 0; }
}
@keyframes draw {
0%, 22% { stroke-dashoffset: 100; }
55%, 100% { stroke-dashoffset: 0; }
}
// ── 접근성: 움직임 줄이기 설정이면 완성된 상태색 체크만 남긴다 ────────
@media (prefers-reduced-motion: reduce) {
.toast.is-demo .badge__neutral { animation: none; opacity: 0; }
.toast.is-demo .badge__colored { animation: none; opacity: 1; }
.toast.is-demo .badge__check { animation: none; stroke-dashoffset: 0; }
}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);
}
.toast {
display: flex;
align-items: center;
gap: 12px;
width: min(280px, 84vw);
padding: 16px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.badge {
position: relative;
display: grid;
place-items: center;
width: 28px;
height: 28px;
flex: none;
}
.badge__layer {
grid-area: 1/1;
}
.badge__neutral {
color: #17141a;
opacity: 0.3;
}
.badge__colored {
color: #2f6df6;
opacity: 0;
}
.badge__check {
stroke-dasharray: 100;
stroke-dashoffset: 100;
}
.toast__title {
margin: 0 0 4px;
font-weight: 700;
font-size: 15px;
line-height: 1.3;
font-family: inherit;
}
.toast__text {
margin: 0;
font-weight: 400;
font-size: 13px;
line-height: 1.4;
font-family: inherit;
opacity: 0.78;
}
.toast.is-demo .badge__neutral {
animation: fade-out 2s ease-out infinite;
}
.toast.is-demo .badge__colored {
animation: fade-in 2s ease-out infinite;
}
.toast.is-demo .badge__check {
animation: draw 2s ease-out infinite;
}
@keyframes fade-out {
0%, 15% {
opacity: 0.3;
}
32%, 80% {
opacity: 0;
}
95%, 100% {
opacity: 0.3;
}
}
@keyframes fade-in {
0%, 15% {
opacity: 0;
}
32%, 80% {
opacity: 1;
}
95%, 100% {
opacity: 0;
}
}
@keyframes draw {
0%, 22% {
stroke-dashoffset: 100;
}
55%, 100% {
stroke-dashoffset: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.toast.is-demo .badge__neutral {
animation: none;
opacity: 0;
}
.toast.is-demo .badge__colored {
animation: none;
opacity: 1;
}
.toast.is-demo .badge__check {
animation: none;
stroke-dashoffset: 0;
}
}import type { CSSProperties } from "react";
import styles from "./StatusIconToast.module.scss";
export type StatusIconToastProps = {
title: string;
message: string;
/** 상태색. 성공은 파랑, 실패는 error 색을 넘긴다 */
color?: string;
resolved?: boolean;
};
type Vars = CSSProperties & { "--color"?: string };
export default function StatusIconToast({
title,
message,
color = "#2f6df6",
resolved = true,
}: StatusIconToastProps) {
const vars: Vars = { "--color": color };
return (
<div className={styles.toast} style={vars} role="status" aria-live="polite">
<span className={styles.badge} aria-hidden="true">
<svg className={`${styles.layer} ${styles.neutral}`} viewBox="0 0 24 24" width={28} height={28} style={{ opacity: resolved ? 0 : 0.3 }}>
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth={2} />
</svg>
<svg className={`${styles.layer} ${styles.colored}`} viewBox="0 0 24 24" width={28} height={28} style={{ opacity: resolved ? 1 : 0 }}>
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth={2} />
<polyline
className={styles.check}
points="7 12.5 10.5 16 17 8.5"
pathLength={100}
fill="none"
stroke="currentColor"
strokeWidth={2.6}
strokeLinecap="round"
strokeLinejoin="round"
style={{ strokeDashoffset: resolved ? 0 : 100 }}
/>
</svg>
</span>
<div className={styles.body}>
<p className={styles.title}>{title}</p>
<p className={styles.text}>{message}</p>
</div>
</div>
);
}07 · 成功アイコン — 計測 累積面積 0.215% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
エラー色に替えるだけで失敗バッジ
.badge__layer { grid-area: 1 / 1; }
.badge__neutral { opacity: .3; }
.badge__colored { opacity: 0; }
.badge__check { stroke-dasharray: 100; stroke-dashoffset: 100; }
.toast.is-demo .badge__neutral { animation: fade-out $dur-loop $easing infinite; }
.toast.is-demo .badge__colored { animation: fade-in $dur-loop $easing infinite; }
.toast.is-demo .badge__check { animation: draw $dur-loop $easing infinite; }
08モバイル下部トースト
safe-area-inset-bottomを足す
画面下端に固定し、bottomにenv(safe-area-inset-bottom, 0px)を足してノッチやホームインジケーターを避けます。translateY(140%)から0への移動とopacityが同時に動き、ピル型の一行バーが持ち上がって収まります。「トースト 通知 スマホ」で求められる形で、ハンドル付きのボトムシートと違い一行だけ表示してタイマーで自動的に閉じます。

<main class="stage">
<div class="toast is-demo" role="status" aria-live="polite">
<p class="toast__text">新着情報が届きました</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-enter; // 600ms — 아래에서 올라오는 시간
$easing: $ease-spring;
$color: $subject-blue; // 글자 대비용 참고색(테두리 없음, 변형에서 강조선에 쓴다)
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
// 모바일 하단 고정 스트립 — 03 편의 bottom-sheet(손잡이 달린 큰 패널)와 달리
// 한 줄짜리 알약형이고 안전영역을 고려해 자동으로 사라진다 (모달이 아니라 알림이다)
.toast {
position: fixed;
left: $sp-4; right: $sp-4;
bottom: calc(env(safe-area-inset-bottom, 0px) + #{$sp-4});
margin: 0 auto;
max-width: 340px;
padding: $sp-3 $sp-5;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
box-shadow: $shadow-raised;
text-align: center;
transform: translateY(140%);
opacity: 0;
}
.toast__text { margin: 0; font-weight: 600; font-size: 13px; line-height: 1.4; font-family: inherit; }
.toast.is-demo { animation: bottom-loop $dur-loop $easing infinite; }
@keyframes bottom-loop {
0% { transform: translateY(140%); opacity: 0; }
20% { transform: translateY(0); opacity: 1; }
78% { transform: translateY(0); opacity: 1; }
100% { transform: translateY(140%); opacity: 0; }
}
// ── 접근성: 움직임 줄이기 설정이면 뜬 채로 고정 ────────
@media (prefers-reduced-motion: reduce) {
.toast.is-demo { animation: none; transform: translateY(0); opacity: 1; }
}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);
}
.toast {
position: fixed;
left: 16px;
right: 16px;
bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
margin: 0 auto;
max-width: 340px;
padding: 12px 20px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
text-align: center;
transform: translateY(140%);
opacity: 0;
}
.toast__text {
margin: 0;
font-weight: 600;
font-size: 13px;
line-height: 1.4;
font-family: inherit;
}
.toast.is-demo {
animation: bottom-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes bottom-loop {
0% {
transform: translateY(140%);
opacity: 0;
}
20% {
transform: translateY(0);
opacity: 1;
}
78% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(140%);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.toast.is-demo {
animation: none;
transform: translateY(0);
opacity: 1;
}
}import type { CSSProperties } from "react";
import styles from "./BottomMobileToast.module.scss";
export type BottomMobileToastProps = {
message: string;
visible: boolean;
duration?: number;
easing?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
export default function BottomMobileToast({
message,
visible,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
}: BottomMobileToastProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
return (
<div className={`${styles.toast} ${visible ? styles.isVisible : ""}`} style={vars} role="status" aria-live="polite">
<p className={styles.text}>{message}</p>
</div>
);
}08 · モバイル下部トースト — 計測 累積面積 15.832% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ボトムシートとの違い
.toast {
position: fixed;
left: 16px; right: 16px;
bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
margin: 0 auto;
max-width: 340px;
border-radius: 999px;
transform: translateY(140%);
opacity: 0;
}
.toast.is-demo { animation: bottom-loop $dur-loop $easing infinite; }
09通知バッジ
先端を軸に減衰しながら回る
ベルの<g>にtransform-box: fill-boxとtransform-origin: 50% 0%を与え、先端を軸に左右へ減衰しながら回転させます。右上のバッジは同じタイミングでscaleが脈打つように拡大・縮小します。ヘッダーの通知アイコンや未読件数の表示に使います。

<main class="stage">
<button class="bell is-demo" type="button" aria-label="通知3件">
<svg viewBox="0 0 24 24" width="32" height="32" aria-hidden="true">
<g class="bell__body">
<path d="M12 3a5 5 0 0 0-5 5v3.2c0 .9-.35 1.77-.98 2.4L5 15h14l-1.02-1.4a3.4 3.4 0 0 1-.98-2.4V8a5 5 0 0 0-5-5Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M10 18a2 2 0 0 0 4 0" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</g>
</svg>
<span class="badge" aria-hidden="true">3</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base; // 300ms — 종이 흔들리는 시간
$easing: $ease-out;
$color: $subject-blue; // 배지 색
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.bell {
position: relative;
display: grid; place-items: center;
width: 56px; height: 56px;
border: 0; border-radius: $r-control;
background: transparent;
color: $subject-cream;
cursor: pointer;
&:hover, &:focus-visible { background: rgba(255, 255, 255, .08); }
}
.bell__body {
transform-box: fill-box;
transform-origin: 50% 0%; // 종의 꼭지점을 축으로 흔든다
}
.badge {
position: absolute; top: $sp-1; right: $sp-1;
display: grid; place-items: center;
min-width: 18px; height: 18px;
padding: 0 $sp-1;
border-radius: $r-pill;
background: $color;
color: #fff;
font-weight: 700;
font-size: 10px;
line-height: 1;
font-family: inherit;
}
// 실측 2026-09-10: 흔들림과 배지 맥동을 같은 구간에 겹쳐 "새 알림이 왔다"는 한 순간을 만든다
.bell.is-demo .bell__body { animation: shake $dur-loop $easing infinite; }
.bell.is-demo .badge { animation: pulse $dur-loop $easing infinite; }
@keyframes shake {
0%, 55%, 100% { transform: rotate(0deg); }
60% { transform: rotate(-14deg); }
65% { transform: rotate(12deg); }
70% { transform: rotate(-8deg); }
75% { transform: rotate(5deg); }
80% { transform: rotate(0deg); }
}
@keyframes pulse {
0%, 55%, 100% { transform: scale(1); }
60% { transform: scale(1.3); }
70% { transform: scale(1); }
}
// ── 접근성: 움직임 줄이기 설정이면 흔들지 않고 배지만 남긴다 ────────
@media (prefers-reduced-motion: reduce) {
.bell.is-demo .bell__body { animation: none; }
.bell.is-demo .badge { animation: 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);
}
.bell {
position: relative;
display: grid;
place-items: center;
width: 56px;
height: 56px;
border: 0;
border-radius: 12px;
background: transparent;
color: #fff7e6;
cursor: pointer;
}
.bell:hover, .bell:focus-visible {
background: rgba(255, 255, 255, 0.08);
}
.bell__body {
transform-box: fill-box;
transform-origin: 50% 0%;
}
.badge {
position: absolute;
top: 4px;
right: 4px;
display: grid;
place-items: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 999px;
background: #2f6df6;
color: #fff;
font-weight: 700;
font-size: 10px;
line-height: 1;
font-family: inherit;
}
.bell.is-demo .bell__body {
animation: shake 2s ease-out infinite;
}
.bell.is-demo .badge {
animation: pulse 2s ease-out infinite;
}
@keyframes shake {
0%, 55%, 100% {
transform: rotate(0deg);
}
60% {
transform: rotate(-14deg);
}
65% {
transform: rotate(12deg);
}
70% {
transform: rotate(-8deg);
}
75% {
transform: rotate(5deg);
}
80% {
transform: rotate(0deg);
}
}
@keyframes pulse {
0%, 55%, 100% {
transform: scale(1);
}
60% {
transform: scale(1.3);
}
70% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.bell.is-demo .bell__body {
animation: none;
}
.bell.is-demo .badge {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./BellBadge.module.scss";
export type BellBadgeProps = {
count: number;
onClick?: () => void;
color?: string;
};
type Vars = CSSProperties & { "--color"?: string };
export default function BellBadge({ count, onClick, color = "#2f6df6" }: BellBadgeProps) {
const vars: Vars = { "--color": color };
return (
<button className={styles.bell} style={vars} type="button" aria-label={`알림 ${count}개`} onClick={onClick}>
<svg viewBox="0 0 24 24" width={32} height={32} aria-hidden="true">
<g className={styles.body}>
<path
d="M12 3a5 5 0 0 0-5 5v3.2c0 .9-.35 1.77-.98 2.4L5 15h14l-1.02-1.4a3.4 3.4 0 0 1-.98-2.4V8a5 5 0 0 0-5-5Z"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinejoin="round"
/>
<path d="M10 18a2 2 0 0 0 4 0" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" />
</g>
</svg>
{count > 0 && (
<span className={styles.badge} aria-hidden="true">
{count}
</span>
)}
</button>
);
}09 · 通知バッジ — 計測 累積面積 0.469% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ヘッダーの未読件数に
.bell__body {
transform-box: fill-box;
transform-origin: 50% 0%;
}
.bell.is-demo .bell__body { animation: shake $dur-loop $easing infinite; }
.bell.is-demo .badge { animation: pulse $dur-loop $easing infinite; }
@keyframes shake {
0%, 55%, 100% { transform: rotate(0deg); }
60% { transform: rotate(-14deg); }
65% { transform: rotate(12deg); }
70% { transform: rotate(-8deg); }
}
どこで壊れるか — 落とし穴
grid-areaを書かないと縦に並ぶ
9つとも壊れやすい場所が違いました。1つ目は07の成功アイコンで、無彩色リングと状態色リング+チェックという2枚のSVGにgrid-area: 1 / 1を付け忘れると、重なるはずの2枚が縦に並んでしまい、バッジ全体の高さが2倍になりました。
--iはインラインで振る
2つ目は03の3件スタックです。カードごとに変わるはずの--iをSCSS側に固定値で書いてしまうと、3枚とも同じオフセットを受け取って完全に重なって見えます。要素ごとにstyle="--i:0"のようにインラインで振らないと、デッキが開いて見える形になりません。
既定のUAスタイルは中央固定
3つ目は02のPopover APIトーストです。popover属性の既定UAスタイルは画面中央に固定するもので、position・top・right・marginをすべて上書きしないと角ではなく中央に浮きます。Popover APIはChrome・Edge 114以降、Safari 17以降、Firefox 125以降で使え(caniuseの対応表)、それより古いブラウザではpopover属性自体が無視されて普通の<div>のまま残ります。08で使ったenv(safe-area-inset-bottom)も同様にブラウザ依存で、iOS Safari 11.2以降でしか実際のノッチ値を返しません(caniuseのenv()対応表)。対応しないブラウザではcalc()のフォールバック値0pxが黙って適用されるだけなので、そちらは安全側に倒れます。
setPointerCaptureがないと途切れる
描画の途中に通知が割り込む事故は、ヒーローセクションのアニメーションのように最初の演出が長いページでとくに起きやすいものです。
4つ目は06のスワイプで閉じるです。pointerdownでsetPointerCaptureを呼んでいないと、指がカードの外に出た瞬間pointermoveが届かなくなり、ドラッグがそこで止まってしまいました。ここまで直した9本のファイルを開くパスワードはvp67sjt7で、下のカードから取り出せます。
アクセシビリティ
politeとassertiveの境目
9つとも実装コードにrole="status" aria-live="polite"を持たせています。読み上げソフトには伝えつつ、今の作業を止めさせない強さだからです。prefers-reduced-motion: reduceでは01・02・05・06・08はtransform: noneやopacity: 1に固定して登場・退場のアニメーションだけを止め、03は-22px・opacity: 1という開いた状態のままtransitionを切ります。07は両レイヤーのアニメーションを止め、状態色レイヤーだけを最終状態に固定し、09はベルの回転とバッジの拡大縮小をどちらも止めます。04だけは進行バーのアニメーションを残し、トーストが震える動きだけを取り除きます。進行バーは「いつ閉じるか」を伝える機能的な情報で、装飾ではないからです。5秒という自動で閉じるまでの時間はWCAG 2.2.1 Timing Adjustableが延長・解除の手段を求めており、05の「元に戻す」ボタンがそのままタイマーを止める役割を兼ねています。
次に読む
018のマイクロインタラクション9選にあるトーストの積み重ねは新着が来るたびに既存の分を押し上げるキューで、ここ03の3件スタックは常に3枚が固定されたデッキがhoverで開く点が逆方向です。003のモーダルアニメーション9選にあるボトムシートはハンドル付きの大きなパネルですが、08のモバイル下部トーストはハンドルのない一行のピル型で自動的に閉じます。ほかのCSSアニメーションはカテゴリー一覧に、このサイトの狙いはサイト紹介にまとめてあります。
FAQ
role="status"とrole="alert"、どちらを使うべきですか?
ほとんどのトーストはrole="status" aria-live="polite"が適切です。role="alert"やaria-live="assertive"は読み上げを即座に割り込ませる強さなので、決済失敗やセッション切れのように必ず今知らせたい通知だけに使います。
「トースト 通知 デザイン」で自動で閉じるまで何秒が目安ですか?
決まった正解はありませんが、WCAG 2.2.1は時間制限を延長・解除できる手段を求めています。04は5秒を基準にしつつ、05のように「元に戻す」ボタンでタイマーを止められる仕組みを添えて、読み終わる前に消える事態を避けています。
Popover APIに対応していないブラウザではどうなりますか?
popover属性が使えない環境では要素はただの<div>のまま残るので、'showPopover' in document.createElement('div')で対応状況を確認し、非対応のときだけクラスの付け外しで開閉するJSを別途用意します。通知が何枚も積み上がるときの順序と押し出しはトースト通知UIのスタックで別に扱っています。









