9 CSS Toast Notifications — No Library, Pure CSS
A CSS toast notification is a small card that appears in a corner, holds briefly, and clears itself away so a result never interrupts what someone was doing —
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Slide in from right
- 02 Popover API toast
- 03 3-item stack
- 04 Progress-bar auto-dismiss
- 05 Undo toast
- 06 Swipe to dismiss
- 07 Success icon
- 08 Mobile bottom toast
- 09 Notification badge
These nine toast css examples follow the order a toast actually lives through rather than any popularity ranking: 01 through 03 are the noticeable ways one gets on screen, 04 through 06 are how it clears itself without a click, and 07 through 09 lean on color and shape instead of extra words to say what happened. The same open-and-close grammar becomes a CSS dropdown menu once it hangs off a nav item, and a sidebar drawer once it grows into a panel sliding in from the edge.
01Slide in from right
Why a css toast message starts at 140%
The card sits off-screen at translateX(140%) with opacity 0, slides in to rest, waits, then retraces the same path back out.

<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">Saved</p>
<p class="toast__text">New item saved</p>
</div>
<button class="toast__close" type="button" aria-label="Dismiss notification">
<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 · Slide in from right — Measured area 19.466% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
The default desktop corner shape
It's the default shape for a desktop corner toast confirming a save or an upload finished.
.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; }
}
02Popover API toast
Top layer without JavaScript
Everything opens and closes through a popover attribute and a popovertarget button, so the browser's own top layer handles stacking plus Escape and outside-click dismissal without a line of JavaScript for either.

<main class="stage">
<div class="demo">
<button class="opener" type="button" popovertarget="ptoast048">Show notification again</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">Connected</p>
<p class="toast__text">Network connection restored</p>
</div>
<button class="toast__close" type="button" popovertarget="ptoast048" popovertargetaction="hide" aria-label="Dismiss notification">
<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 toast — Measured area 23.62% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
Overriding the centered default
The popover attribute reaches Chrome and Edge 114, Safari 17, and Firefox 125, though its default styling centers the element, so the corner position seen here comes from overriding that positioning by hand.
<div id="toast" class="toast" popover role="status" aria-live="polite">
<span class="toast__dot" aria-hidden="true"></span>
<p class="toast__title">Connected</p>
<button popovertarget="toast" popovertargetaction="hide" aria-label="Close notification">
<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-item stack
A deck that fans out on hover
Three notifications rest as a barely offset deck, each card reading its own --i custom property to sit a little higher and smaller than the one below it.

<main class="stage">
<div class="stack is-demo" tabindex="0" role="status" aria-live="polite" aria-label="3 notifications">
<div class="stack__item" style="--i:2">Points earned</div>
<div class="stack__item" style="--i:1">Shipment started</div>
<div class="stack__item" style="--i:0">Payment completed</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-item stack — Measured area 9.192% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
Reading each card individually
Hovering the group, or moving focus into it, flattens that offset back out so every card becomes individually readable.
.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;
}
04Progress-bar auto-dismiss
A bar emptying at a constant rate
A bar pinned to the toast's bottom edge shrinks from scaleX(1) to scaleX(0) at a constant rate, and the toast fades out the instant that bar finishes emptying.

<main class="stage">
<div class="toast is-demo" role="status" aria-live="polite">
<div class="toast__body">
<p class="toast__title">Upload complete</p>
<p class="toast__text">Closes automatically in 5 seconds</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 · Progress-bar auto-dismiss — Measured area 23.668% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Closing on a fixed schedule
It's built for anything that has to close on a fixed schedule, like a send or upload confirmation nobody needs to dismiss by hand.
.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); }
}
05Undo toast
Undo clears the pending timer
Clicking Undo next to a delete message clears the pending timer and swaps the text to a cancelled state. Left alone for four seconds, the toast instead folds shut from the top edge via scaleY.

<main class="stage">
<div class="toast is-demo" role="status" aria-live="polite">
<p class="toast__text">Item deleted</p>
<button class="toast__undo" type="button">Undo</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초 뒤 사라지지만, 그 전에 "Undo"를 누르면 타이머를 멈추고 문구를 바꾼다
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 = 'Cancelled';
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 · Undo toast — Measured area 14.713% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Instead of a confirm dialog
It stands in as a toast message example replacing a confirm dialog on list-item deletion, where asking twice before every removal would slow the whole list down.
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 = 'Cancelled';
t.classList.add('is-cancelled');
});
});
06Swipe to dismiss
Writing drag distance into --dx
Holding a pointer down and dragging left or right writes the distance into --dx, so the card tracks the finger in real time through a translateX(var(--dx)) transform. Letting go past an 80px threshold sends it flying out in that direction instead of snapping back.

<main class="stage">
<div class="toast is-demo" role="status" aria-live="polite">
<p class="toast__text">New message received</p>
<span class="toast__hint" aria-hidden="true">Swipe to dismiss</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 · Swipe to dismiss — Measured area 20.664% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
Past 80px it keeps going
Touch-first chat and messenger notification trays are where the gesture reads as natural rather than gimmicky.
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');
});
07Success icon
Two rings crossfading in place
A neutral gray ring fades out via opacity at the same moment a status-colored ring and a stroke-dashoffset checkmark fade in underneath it, so color alone carries the result rather than a second icon swap.
<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">Payment complete</p>
<p class="toast__text">Card payment approved</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 · Success icon — Measured area 0.215% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
One token away from a failure badge
Trading the status token for an error color turns the identical markup into a failure badge instead of a success one.
.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; }
08Mobile bottom toast
Clearing the home indicator
A pill-shaped bar pinned to the bottom edge adds env(safe-area-inset-bottom, 0px) to its own offset so it clears a phone's home indicator, then rises into place before settling back down. That function has been supported since iOS Safari 11.2, with every other browser simply reading the calc() fallback of 0px.

<main class="stage">
<div class="toast is-demo" role="status" aria-live="polite">
<p class="toast__text">New update available</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 · Mobile bottom toast — Measured area 15.832% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
One line, not a bottom sheet
Unlike a handled bottom sheet, it shows exactly one line and closes itself instead of waiting to be dragged away.
.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; }
09Notification badge
Bell and badge on the same beat
A bell icon rocks briefly around a transform-origin pinned to its own tip while the badge in the corner scales up and back on the same timing, marking the exact instant a new count arrives.

<main class="stage">
<button class="bell is-demo" type="button" aria-label="3 notifications">
<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 · Notification badge — Measured area 0.469% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
One event, not a lingering state
It suits an unread-message badge in a header, where the motion needs to read as one event rather than a lingering state.
.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); }
}
Where do these toasts actually break?
Trusting a default instead of writing the rule
Two of the stacked-layer items break for mirror-image reasons rooted in the same habit: trusting a default instead of writing the rule explicitly. Item 07's success icon overlays a neutral ring and a colored ring in the exact same spot using grid-area: 1 / 1 on both SVGs. Drop that rule and the browser stacks them top to bottom instead of on top of each other, doubling the badge's rendered height rather than crossfading in place. Item 03's stack carries the inverse of that bug: the --i custom property has to be written inline on each card's own style attribute. A single fixed value set inside the SCSS file instead gives all three cards an identical offset, collapsing the deck into one flat card.
Two quieter versions of the same problem
A toast cutting into a screen that is still drawing itself happens most often on pages with long opening sequences, such as the hero section animations.
A couple of the other demos hid a quieter version of the same problem. Item 02's popover attribute ships with UA styling that centers itself in the viewport by default. Without an explicit position: fixed plus top, right, and margin overrides, the toast opens dead center instead of tucked into a corner. Item 06's drag needs setPointerCapture called on pointerdown, or the card's pointermove listener stops firing the moment a finger crosses the card's own edge mid-swipe, leaving the drag stuck halfway across the screen. Every one of these got caught, fixed, and re-measured before the archive shipped, and the password that opens it is vp67sjt7, typed exactly as it reads on this line with nothing added or removed.
Accessibility
The base pairing for a screen reader
All nine use the same base pairing for a screen reader:
<div class="toast" role="status" aria-live="polite">
<p>Saved</p>
</div>
When a timer needs a way out
That combination announces a toast without yanking focus away from whatever someone was already doing, which covers most save, upload, and connection-status messages. A login failure or a session timeout carries more urgency than that, so swapping in aria-live="assertive" (or role="alert") is the right call whenever someone genuinely has to act on what the toast says. Item 04's progress-bar auto-dismiss is the one that answers to a written accessibility rule rather than just a role attribute. WCAG 2.2.1, Timing Adjustable requires a way to extend or turn off a time limit. That's why the five-second countdown here sits next to item 05's Undo pattern instead of closing with no way to stop it. prefers-reduced-motion turns off the slide, rotation, and fan-out animations across the other eight. But item 04's bar keeps animating on purpose, since it's the only visual cue for exactly when the auto-close lands rather than decoration layered on top. MDN's prefers-reduced-motion reference documents which operating-system setting flips that media query on.
Where the rest lives
018's vibe-coding micro-interactions post stacks new toasts by pushing older ones up a slot — the opposite direction from this post's fixed three-card deck, which only fans out on hover in item 03. 003's modal animation bottom sheet is a handled panel that stays open until someone dismisses it — nothing like the one-line, self-closing pill in item 08. The rest of this site's CSS work sits on the category page, and what's actually for sale versus what's free to read is on the about page.
FAQ
Which toast message types need role="alert" instead of role="status"?
role="status" paired with aria-live="polite" is right for most toasts, since it announces the message without cutting off whatever a screen reader was already reading aloud. Reach for role="alert" (or aria-live="assertive") only for messages someone truly can't miss, like a failed payment or an expired session.
How long should a toast stay up before it closes itself?
There's no single correct number, but WCAG 2.2.1 requires a way to extend, postpone, or turn off any time limit under 20 hours. Item 04 here defaults to five seconds and pairs it with item 05's Undo button so the countdown is never the only way out of a mistake.
Does the Popover API toast work in every browser?
Not yet everywhere — Chrome and Edge have supported the popover attribute since version 114, Safari since 17, and Firefox joined at 125. Feature-detecting with 'showPopover' in document.createElement('div') and falling back to a manually toggled class covers whatever's left on older browsers. What happens once several notices pile up — their order, their offset, their dismissal — is handled separately in the toast stacking UI patterns.









