04남은 시간 바
바닥의 진행바가 scaleX 로 등속으로 줄어들다가 다 닳는 순간과 같은 타이밍에 토스트가 사라진다.
쓰임업로드·전송 완료처럼 정해진 시간 뒤 자동으로 닫혀야 하는 알림
scaleXlinearanimationend

<main class="stage">
<div class="toast is-demo" role="status" aria-live="polite">
<div class="toast__body">
<p class="toast__title">업로드 완료</p>
<p class="toast__text">5초 후 자동으로 닫힙니다</p>
</div>
<span class="toast__bar" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: 5000ms; // 진행바가 다 닳아 닫히기까지 실제 시간 (UI 모션이 아니라 타이머 값이라 토큰 밖)
$easing: linear; // 진행바는 등속으로 — 급가속하면 "얼마 남았는지" 신뢰가 깨진다
$color: $subject-blue; // 진행바 색
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.toast {
position: relative;
overflow: hidden;
width: min(280px, 84vw);
padding: $sp-4;
border-radius: $r-card;
background: $subject-ink;
color: $subject-cream;
box-shadow: $shadow-raised;
}
.toast__title { margin: 0 0 $sp-1; font-weight: 700; font-size: 15px; line-height: 1.3; font-family: inherit; }
.toast__text { margin: 0; font-weight: 400; font-size: 13px; line-height: 1.4; font-family: inherit; opacity: .78; }
.toast__bar {
position: absolute; left: 0; bottom: 0;
width: 100%; height: $sp-1;
background: $color;
transform-origin: left; // 규칙8: 진행바 왼쪽 끝 고정
transform: scaleX(1);
}
// 실제 사용: 진행바의 animationend 가 곧 닫힘 신호다. bar.addEventListener('animationend', () => toast.remove())
// 데모는 이 사이클(진행바가 다 닳고 → 토스트가 사라짐)을 전역 루프(2s) 안에 압축해 반복한다
.toast.is-demo .toast__bar { animation: bar-shrink $dur-loop $easing infinite; }
.toast.is-demo { animation: toast-out $dur-loop $ease-out infinite; }
@keyframes bar-shrink {
0% { transform: scaleX(1); }
82% { transform: scaleX(0); }
100% { transform: scaleX(0); }
}
@keyframes toast-out {
0%, 82% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-8px); }
}
// ── 접근성: 움직임 줄이기 설정이면 진행바만 조용히 줄고 토스트는 안 흔들린다 ────────
@media (prefers-reduced-motion: reduce) {
.toast.is-demo { animation: none; }
.toast.is-demo .toast__bar { animation: bar-shrink $duration linear forwards; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.toast {
position: relative;
overflow: hidden;
width: min(280px, 84vw);
padding: 16px;
border-radius: 16px;
background: #17141a;
color: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.toast__title {
margin: 0 0 4px;
font-weight: 700;
font-size: 15px;
line-height: 1.3;
font-family: inherit;
}
.toast__text {
margin: 0;
font-weight: 400;
font-size: 13px;
line-height: 1.4;
font-family: inherit;
opacity: 0.78;
}
.toast__bar {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 4px;
background: #2f6df6;
transform-origin: left;
transform: scaleX(1);
}
.toast.is-demo .toast__bar {
animation: bar-shrink 2s linear infinite;
}
.toast.is-demo {
animation: toast-out 2s ease-out infinite;
}
@keyframes bar-shrink {
0% {
transform: scaleX(1);
}
82% {
transform: scaleX(0);
}
100% {
transform: scaleX(0);
}
}
@keyframes toast-out {
0%, 82% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-8px);
}
}
@media (prefers-reduced-motion: reduce) {
.toast.is-demo {
animation: none;
}
.toast.is-demo .toast__bar {
animation: bar-shrink 5000ms linear forwards;
}
}import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./ProgressCloseToast.module.scss";
export type ProgressCloseToastProps = {
title: string;
message: string;
onDismiss?: () => void;
/** 진행바가 다 닳기까지 실제 시간 (기본 5000ms) */
duration?: number;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--color"?: string };
export default function ProgressCloseToast({
title,
message,
onDismiss,
duration = 5000,
color = "#2f6df6",
}: ProgressCloseToastProps) {
const barRef = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
const bar = barRef.current;
if (!bar || !onDismiss) return;
const onEnd = () => onDismiss();
bar.addEventListener("animationend", onEnd);
return () => bar.removeEventListener("animationend", onEnd);
}, [onDismiss]);
const vars: Vars = { "--duration": `${duration}ms`, "--color": color };
return (
<div className={styles.toast} style={vars} role="status" aria-live="polite">
<div className={styles.body}>
<p className={styles.title}>{title}</p>
<p className={styles.text}>{message}</p>
</div>
<span ref={barRef} className={styles.bar} aria-hidden="true" />
</div>
);
}04 남은 시간 바 — 측정 누적면적 23.668% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 웹 토스트 알림 CSS 애니메이션 9종 — JS 없이, CSS만으로
이 데모가 속한 글
웹 토스트 알림 CSS 애니메이션 9종 — JS 없이, CSS만으로웹 토스트 알림은 화면 한쪽에 잠깐 떴다가 스스로 사라지는 작은 알림 카드로, 사용자가 지금 보던 화면을 벗어나지 않아도 결과를 알 수 있게 해 줍니다. 슬라이드로 들어오는 것부터 스와이프로 미는 것, 진행바가 다 이 글의 데모 전부 보기


