04Thanh thời gian còn lại
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.
Dùng choThông báo phải tự đóng sau một mốc thời gian cố định như xác nhận tải lên
scaleXlinearanimationend

<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 · 9 CSS Toast Notification — không cần JS
Bài viết chứa demo này
9 CSS Toast Notification — không cần JSCSS 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. Xem tất cả demo trong bài này


