9 CSS Toast Notification — không cần JS
CSS toast notification là thẻ thông báo nhỏ tự nổi lên một góc màn hình rồi tự đóng, dựng bằng CSS thuần, không cần JS.
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
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Trượt vào từ bên phải
- 02 Toast Popover API
- 03 Xếp chồng 3 lớp
- 04 Thanh thời gian còn lại
- 05 Toast hoàn tác
- 06 Vuốt để đóng
- 07 Icon thành công
- 08 Toast đáy màn hình di động
- 09 Huy hiệu thông báo
Chín kiểu không xếp theo độ nổi tiếng mà theo đúng vòng đời của một toast trên màn hình: cách xuất hiện gây chú ý (01–03), cách tự dọn mình (04–06), rồi cách dùng màu và hình để nói trạng thái (07–09). Ở lưới phía trên, mỗi ô lặp lại bằng class .is-demo theo chu kỳ hai giây bên trong một iframe cố định không nhận sự kiện chuột thật, nên bạn xem chuyển động lặp chứ chưa bấm nút nào cả — file vanilla riêng trong zip mới phản hồi đúng thao tác thật của bạn.
01Trượt vào từ bên phải
Bạn thấy gì
Card trượt vào từ ngoài mép phải màn hình bằng translateX, dừng lại một nhịp rồi thoát ra đúng hướng cũ.

<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">Đã lưu</p>
<p class="toast__text">Mục mới đã được lưu</p>
</div>
<button class="toast__close" type="button" aria-label="Đóng thông báo">
<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 · Trượt vào từ bên phải — Diện tích đo được 19.466% · khung hình chuyển động 9/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Đây là cách cơ bản nhất cho thông báo lưu hay tải lên hoàn tất, đặt ở góc một web app desktop.
.toast {
position: relative;
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; }
}
02Toast Popover API
Chuyện gì diễn ra trên màn hình
Chỉ cần thuộc tính popover và nút popovertarget là toast mở đóng được, đứng sẵn ở top-layer và được đóng bằng Esc hay click ra ngoài mà không cần dòng JS nào cho việc đóng.

<main class="stage">
<div class="demo">
<button class="opener" type="button" popovertarget="ptoast048">Xem lại thông báo</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">Đã kết nối</p>
<p class="toast__text">Mạng đã khôi phục</p>
</div>
<button class="toast__close" type="button" popovertarget="ptoast048" popovertargetaction="hide" aria-label="Đóng thông báo">
<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 · Toast Popover API — Diện tích đo được 23.62% · khung hình chuyển động 9/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Style mặc định lại canh giữa màn hình, nên muốn đặt ở góc thì phải tự ghi đè.
<div id="ptoast" class="toast" popover role="status" aria-live="polite">
<span class="toast__dot" aria-hidden="true"></span>
<p class="toast__title">Đã kết nối</p>
<button popovertarget="ptoast" popovertargetaction="hide" aria-label="Đóng thông báo">
<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('ptoast').showPopover();</script>
03Xếp chồng 3 lớp
Chuyển động thế nào
Ba thẻ thông báo nằm chồng khẽ lệch nhau nhờ biến CSS --i gán riêng từng thẻ, hover hoặc focus vào cả cụm thì chúng bung ra theo hình quạt để đọc được từng thẻ.

<main class="stage">
<div class="stack is-demo" tabindex="0" role="status" aria-live="polite" aria-label="3 thông báo">
<div class="stack__item" style="--i:2">Điểm đã được cộng</div>
<div class="stack__item" style="--i:1">Đơn hàng đã bắt đầu giao</div>
<div class="stack__item" style="--i:0">Thanh toán hoàn tất</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 · Xếp chồng 3 lớp — Diện tích đo được 9.192% · khung hình chuyển động 16/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Hợp với hộp thông báo dashboard khi nhiều sự kiện đến gần như cùng lúc.
.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;
}
04Thanh thời gian còn lại
Bạn thấy gì
Thanh tiến trình ở đáy co lại đều bằng scaleX theo thời gian tuyến tính, và đúng lúc thanh co hết thì toast biến mất theo cùng nhịp.

<main class="stage">
<div class="toast is-demo" role="status" aria-live="polite">
<div class="toast__body">
<p class="toast__title">Tải lên xong</p>
<p class="toast__text">Tự đóng sau 5 giây</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 · Thanh thời gian còn lại — Diện tích đo được 23.668% · khung hình chuyển động 23/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Dùng cho thông báo phải tự đóng sau một mốc thời gian cố định, kiểu xác nhận tải lên.
.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); }
}
05Toast hoàn tác
Chuyện gì diễn ra trên màn hình
Bấm nút hoàn tác cạnh dòng chữ xoá thì JS huỷ bộ đếm giờ và đổi chữ báo đã huỷ; không bấm thì sau 4 giây toast tự cuộn lại từ trên xuống bằng scaleY.

<main class="stage">
<div class="toast is-demo" role="status" aria-live="polite">
<p class="toast__text">Đã xoá mục</p>
<button class="toast__undo" type="button">Hoàn tác</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초 뒤 사라지지만, 그 전에 "Hoàn tác"를 누르면 타이머를 멈추고 문구를 바꾼다
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 = 'Đã huỷ';
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 · Toast hoàn tác — Diện tích đo được 14.713% · khung hình chuyển động 11/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Dùng thay cho hộp xác nhận khi xoá một dòng trong danh sách.
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 = 'Đã huỷ';
t.classList.add('is-cancelled');
});
});
06Vuốt để đóng
Chuyển động thế nào
Giữ và kéo ngang thì thẻ bám theo ngón tay qua biến --dx, buông tay sau khi kéo quá 80px thì thẻ trượt hẳn ra theo hướng đó để đóng.

<main class="stage">
<div class="toast is-demo" role="status" aria-live="polite">
<p class="toast__text">Có tin nhắn mới</p>
<span class="toast__hint" aria-hidden="true">Vuốt để đóng</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 · Vuốt để đóng — Diện tích đo được 20.664% · khung hình chuyển động 9/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Hợp với hộp thông báo chat, messenger trên di động, nơi thao tác chạm là chính.
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');
});
07Icon thành công
Bạn thấy gì
Vòng tròn màu trung tính mờ dần biến mất đúng lúc vòng màu trạng thái cùng dấu tick vẽ bằng stroke-dashoffset hiện lên chồng lên nhau, nên chính màu sắc nói lên kết quả.
<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">Thanh toán xong</p>
<p class="toast__text">Thanh toán thẻ đã được duyệt</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 · Icon thành công — Diện tích đo được 0.215% · khung hình chuyển động 14/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Đổi $color sang màu lỗi là có ngay badge báo thất bại.
.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; }
08Toast đáy màn hình di động
Chuyện gì diễn ra trên màn hình
Thanh hình viên thuốc cố định ở đáy màn hình né vùng an toàn rồi nhích lên một chút trước khi hạ xuống lại.

<main class="stage">
<div class="toast is-demo" role="status" aria-live="polite">
<p class="toast__text">Có tin mới</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 · Toast đáy màn hình di động — Diện tích đo được 15.832% · khung hình chuyển động 9/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Khác bottom sheet có tay cầm, nó chỉ hiện một dòng và tự đóng, hợp với tin mới hay mất kết nối trên web di động.
.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; }
09Huy hiệu thông báo
Chuyển động thế nào
Icon chuông lắc nhẹ quanh đỉnh của nó đúng lúc badge góc trên phải phồng rồi xẹp lại, tạo một khoảnh khắc rõ ràng báo có thông báo mới.

<main class="stage">
<button class="bell is-demo" type="button" aria-label="3 thông báo">
<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 · Huy hiệu thông báo — Diện tích đo được 0.469% · khung hình chuyển động 8/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Dùng cho icon chuông trên header hiển thị số tin chưa đọc.
.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); }
}
Chỗ dễ hỏng ở đâu — cái bẫy
Hai lớp chồng nhau
Cả chín toast chỉ động đến transform và opacity, vì animate width hay top bắt trình duyệt tính lại layout ở từng khung hình. Nhưng khi xếp hai lớp chồng nhau lại lộ ra một bẫy khác. 07 icon thành công đặt vòng trung tính và vòng màu trạng thái vào cùng một chỗ rồi đổi opacity qua lại — quên grid-area: 1 / 1 trên cả hai lớp thì chúng xếp dọc lên nhau, khiến badge cao gấp đôi. 03 xếp chồng 3 lớp gặp bẫy cùng gốc: gán --i cố định ngay trong style.scss thì cả ba thẻ nhận đúng một giá trị và nằm đè khít lên nhau, nên bắt buộc phải ghi style="--i:N" riêng cho từng phần tử, mỗi thẻ một giá trị khác nhau (0, 1, 2).
Bóng đổ ló ra mép phải
01 trượt vào từ bên phải dùng translateX(140%) chứ không phải 100%, vì thẻ có box-shadow: nếu chỉ đẩy đúng bằng chiều rộng thẻ thì rìa bóng đổ vẫn ló ra mép phải màn hình.
Popover API và vùng an toàn
02 toast Popover API cũng từng vấp: style mặc định của thuộc tính popover là cố định giữa màn hình, không ghi đè đầy đủ bằng position: fixed cùng top, right, margin thì toast nổi giữa màn hình chứ không nằm ở góc. Popover API chạy được từ Chrome và Edge 114 trở lên, Safari 17 trở lên, Firefox 125 trở lên theo bảng hỗ trợ trên caniuse; trình duyệt cũ hơn bỏ qua thuộc tính popover và để lại một <div> bình thường nên phải tự thêm JS bật tắt. 08 toast đáy di động dùng env(safe-area-inset-bottom, 0px) cũng theo kiểu đó — từ iOS Safari 11.2 trở lên theo tài liệu MDN về env() nó trả về đúng giá trị notch hay thanh home thật, còn trình duyệt không hỗ trợ thì calc() âm thầm dùng luôn giá trị dự phòng 0px. 06 vuốt để đóng thì nếu không gọi setPointerCapture ngay khi nhấn, ngón tay vừa vượt mép thẻ là sự kiện pointermove đứt mất và cú kéo dừng khựng giữa chừng. Sửa cả ba chỗ grid-area, --i inline, và setPointerCapture rồi đo lại render xong mới đóng vào file nén, mật khẩu mở ra là vp67sjt7, gõ đúng như nó hiện trên dòng này.
Khả năng tiếp cận
Vai trò status và aria-live
Cả chín toast đều gắn role="status" aria-live="polite", đúng mức để báo cho người dùng trình đọc màn hình mà không ngắt ngang việc họ đang làm.
<div class="toast" role="status" aria-live="polite">
<p class="toast__text">Đã lưu</p>
</div>
Với thông báo cần phản hồi ngay, như đăng nhập thất bại, nên nâng lên aria-live="assertive" để nó chen ngang lập tức. 04 thanh thời gian còn lại tự đóng nên phải theo WCAG 2.2.1 Timing Adjustable, vốn yêu cầu luôn có cách kéo dài hoặc tắt giới hạn thời gian — bản thân demo 04 không có nút dừng, nên khi dùng thật nên ghép thêm nút hoàn tác như mục 05 để đáp ứng yêu cầu này, còn ở đây mốc thời gian được đặt tối thiểu 5 giây. prefers-reduced-motion: reduce tắt gần hết animation trượt, xoay, bung ra, riêng thanh tiến trình của 04 vẫn giữ nguyên vì đó là thông tin chức năng báo khi nào toast tự đóng chứ không phải trang trí. Cách media query này được bật và mức hỗ trợ trình duyệt nằm ở tài liệu MDN.
Xem chín kiểu CSS khác đang chờ trong chuyên mục CSS, còn lý do mỗi file nén ở đây đều có khoá nằm ở trang giới thiệu.
FAQ
role="status" hay role="alert" phù hợp hơn cho toast?
Phần lớn toast hợp với role="status" aria-live="polite". role="alert" (hoặc aria-live="assertive") ngắt ngang luôn trình đọc màn hình, nên chỉ dành cho thông báo bắt buộc phải biết ngay, như thanh toán thất bại hay phiên đăng nhập hết hạn.
Toast tự đóng thì nên đặt bao nhiêu giây?
Không có con số chuẩn, nhưng WCAG 2.2.1 buộc phải có cách kéo dài hay tắt giới hạn thời gian. Mục 04 trong bài này để mặc định 5 giây; bản thân nó không có nút hoàn tác, nên khi dùng thật nên ghép thêm nút hoàn tác như mục 05, để không ai bị mất thông báo trước khi đọc xong.
Trình duyệt chưa hỗ trợ Popover API thì xử lý sao?
Trình duyệt không hỗ trợ sẽ để popover lại thành một <div> bình thường, nên chỉ cần kiểm if ('showPopover' in document.createElement('div')) rồi tự viết thêm vài dòng JS toggle class để mở đóng khi không có sẵn hỗ trợ.









