03残り時間バーと一時停止
下のバーが横に縮んで残り時間を示し、マウスを乗せるかキーボードの焦点が入るとバーと閉じるタイマーが一緒に止まり、離れると続きから流れます。
使いどころ読むのに数秒かかる案内を自動で閉じる通知
scaleXanimation-play-statetransform-origin

<main class="stage">
<div class="pb is-demo">
<div class="pb__box">
<div class="pb__toast is-on" id="pb-toast" role="region" aria-label="自動で閉じる通知" tabindex="0">
<div class="pb__row">
<span class="pb__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
<span class="pb__txt">
<span class="pb__msg">レポートを書き出しました</span>
<span class="pb__sub">まもなく自動で閉じます</span>
</span>
<span class="pb__hold" id="pb-hold" aria-hidden="true">
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" opacity=".2"/><path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40ZM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40Z"/></g></svg>
<span class="pb__holdtxt">一時停止</span>
</span>
<button class="pb__x" type="button" id="pb-x" aria-label="通知を閉じる">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></g></svg>
</button>
</div>
<span class="pb__track" aria-hidden="true"><span class="pb__fill" id="pb-fill"></span></span>
</div>
</div>
<button class="pb__go" type="button" id="pb-go">お知らせを出す</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.pb {
box-sizing: border-box;
width: min(404px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.pb__box {
position: relative;
overflow: hidden;
height: 76px;
}
.pb__toast {
position: absolute;
left: 0;
right: 0;
top: 4px;
box-sizing: border-box;
overflow: hidden;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
transform-origin: top center; // 규칙8: 순수 이동, origin 무의미
opacity: 0;
transform: translateY(-24px);
transition: opacity $duration $easing, transform $duration $easing;
}
.pb__toast.is-on { opacity: 1; transform: translateY(0); }
.pb__toast:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.pb__row {
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-3 $sp-4;
}
.pb__ic { display: inline-flex; flex: 0 0 auto; color: $color; }
.pb__ic svg { display: block; }
.pb__txt { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; }
.pb__msg {
font-size: 13px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pb__sub {
font-size: 11px;
line-height: 16px;
font-weight: 500;
color: rgba($stage-ink, .72);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// 멈춤 표시 — 사람이 올려 놓고 있는 동안만 나온다
.pb__hold {
display: inline-flex;
align-items: center;
gap: $sp-1;
flex: 0 0 auto;
padding: 0 $sp-2;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
opacity: 0;
}
.pb__hold svg { display: block; }
.pb__holdtxt { font-size: 10px; line-height: 18px; font-weight: 700; }
.pb__toast.is-hold .pb__hold { opacity: 1; }
.pb__x {
display: inline-flex;
flex: 0 0 auto;
margin: 0;
padding: $sp-1;
border: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
color: $subject-ink;
cursor: pointer;
}
.pb__x svg { display: block; }
.pb__x:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// 남은 시간 바 — scaleX 는 레이아웃을 다시 계산하지 않는다. 왼쪽을 축으로 잡아야 오른쪽부터 줄어든다
.pb__track {
display: block;
height: 4px;
background: rgba(23, 20, 26, .1);
}
.pb__fill {
display: block;
height: 100%;
background: $color;
transform-origin: left center; // 규칙8: 좌변 축으로 줄어듦
transform: scaleX(1);
}
.pb__toast.is-on .pb__fill {
animation-name: pb-drain;
animation-duration: 4200ms;
animation-timing-function: linear;
animation-iteration-count: 1;
animation-fill-mode: forwards;
}
.pb__toast.is-hold .pb__fill { animation-play-state: paused; }
@keyframes pb-drain {
0% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
.pb__go {
box-sizing: border-box;
align-self: center;
margin: 0;
padding: $sp-2 $sp-4;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-size: 12px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.pb__go:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// ── 자동 루프: 바가 줄다가 가운데서 한 번 멈추고(호버 흉내) 마저 줄어들면 카드가 한 번 내려앉는다 ──
.pb.is-demo .pb__toast {
opacity: 1;
transition: none;
animation-name: pb-card;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 루프에서는 카드를 지우지 않는다 — 사라지는 순간이 곧 "가장 크게 변한 장" 이라 poster 가 빈 화면이 된다.
// 대신 바가 다 닳으면 카드가 한 번 내려앉았다 돌아오고 다음 알림이 들어온 것처럼 바가 다시 찬다
@keyframes pb-card {
0%, 74% { transform: scale(1); }
79% { transform: scale(.955); }
86% { transform: scale(1); }
90%, 100% { transform: scale(1); }
}
.pb.is-demo .pb__fill {
animation-name: pb-loop;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pb-loop {
0%, 6% { transform: scaleX(1); }
34% { transform: scaleX(.62); }
51% { transform: scaleX(.62); }
74% { transform: scaleX(0); }
86% { transform: scaleX(0); }
90%, 100% { transform: scaleX(1); }
}
// 멈춤 칩은 샘플 프레임 사이에서 켜고 꺼 반투명 유령이 남지 않게 한다
.pb.is-demo .pb__hold {
animation-name: pb-hold;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pb-hold {
0%, 34% { opacity: 0; }
35%, 51% { opacity: 1; }
52%, 100% { opacity: 0; }
}
@media (max-width: 340px) {
.pb { gap: $sp-2; }
.pb__box { height: 64px; }
.pb__row { gap: $sp-2; padding: $sp-2 $sp-3; }
.pb__msg { font-size: 12px; line-height: 16px; }
.pb__sub { font-size: 10px; line-height: 14px; }
.pb__go { padding: $sp-1 $sp-3; font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.pb.is-demo .pb__toast,
.pb.is-demo .pb__fill,
.pb.is-demo .pb__hold,
.pb__toast.is-on .pb__fill { animation: none !important; }
.pb.is-demo .pb__toast { opacity: 1; transform: none; }
.pb__toast { transition: none; }
}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);
}
.pb {
box-sizing: border-box;
width: min(404px, 88vw);
display: flex;
flex-direction: column;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.pb__box {
position: relative;
overflow: hidden;
height: 76px;
}
.pb__toast {
position: absolute;
left: 0;
right: 0;
top: 4px;
box-sizing: border-box;
overflow: hidden;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: top center;
opacity: 0;
transform: translateY(-24px);
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pb__toast.is-on {
opacity: 1;
transform: translateY(0);
}
.pb__toast:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.pb__row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
}
.pb__ic {
display: inline-flex;
flex: 0 0 auto;
color: #2f6df6;
}
.pb__ic svg {
display: block;
}
.pb__txt {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
}
.pb__msg {
font-size: 13px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pb__sub {
font-size: 11px;
line-height: 16px;
font-weight: 500;
color: rgba(23, 20, 26, 0.72);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pb__hold {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
padding: 0 8px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
opacity: 0;
}
.pb__hold svg {
display: block;
}
.pb__holdtxt {
font-size: 10px;
line-height: 18px;
font-weight: 700;
}
.pb__toast.is-hold .pb__hold {
opacity: 1;
}
.pb__x {
display: inline-flex;
flex: 0 0 auto;
margin: 0;
padding: 4px;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
cursor: pointer;
}
.pb__x svg {
display: block;
}
.pb__x:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.pb__track {
display: block;
height: 4px;
background: rgba(23, 20, 26, 0.1);
}
.pb__fill {
display: block;
height: 100%;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(1);
}
.pb__toast.is-on .pb__fill {
animation-name: pb-drain;
animation-duration: 4200ms;
animation-timing-function: linear;
animation-iteration-count: 1;
animation-fill-mode: forwards;
}
.pb__toast.is-hold .pb__fill {
animation-play-state: paused;
}
@keyframes pb-drain {
0% {
transform: scaleX(1);
}
100% {
transform: scaleX(0);
}
}
.pb__go {
box-sizing: border-box;
align-self: center;
margin: 0;
padding: 8px 16px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-size: 12px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.pb__go:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.pb.is-demo .pb__toast {
opacity: 1;
transition: none;
animation-name: pb-card;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes pb-card {
0%, 74% {
transform: scale(1);
}
79% {
transform: scale(0.955);
}
86% {
transform: scale(1);
}
90%, 100% {
transform: scale(1);
}
}
.pb.is-demo .pb__fill {
animation-name: pb-loop;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pb-loop {
0%, 6% {
transform: scaleX(1);
}
34% {
transform: scaleX(0.62);
}
51% {
transform: scaleX(0.62);
}
74% {
transform: scaleX(0);
}
86% {
transform: scaleX(0);
}
90%, 100% {
transform: scaleX(1);
}
}
.pb.is-demo .pb__hold {
animation-name: pb-hold;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pb-hold {
0%, 34% {
opacity: 0;
}
35%, 51% {
opacity: 1;
}
52%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.pb {
gap: 8px;
}
.pb__box {
height: 64px;
}
.pb__row {
gap: 8px;
padding: 8px 12px;
}
.pb__msg {
font-size: 12px;
line-height: 16px;
}
.pb__sub {
font-size: 10px;
line-height: 14px;
}
.pb__go {
padding: 4px 12px;
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.pb.is-demo .pb__toast,
.pb.is-demo .pb__fill,
.pb.is-demo .pb__hold,
.pb__toast.is-on .pb__fill {
animation: none !important;
}
.pb.is-demo .pb__toast {
opacity: 1;
transform: none;
}
.pb__toast {
transition: none;
}
}// 실제 동작: 진행 바는 CSS 애니메이션이라 animation-play-state 로 멈추고, 닫힘 타이머는 남은 시간을 계산해 다시 건다
var LIFE = 4200;
var root = document.querySelector('.pb');
var toast = document.getElementById('pb-toast');
var timer = 0, rest = LIFE, start = 0;
function show() {
root.classList.remove('is-demo');
toast.classList.remove('is-on', 'is-hold');
void toast.offsetWidth; // 같은 알림을 다시 띄워도 바가 처음부터 줄게 리플로를 강제한다
toast.classList.add('is-on');
rest = LIFE; start = Date.now();
clearTimeout(timer); timer = setTimeout(hide, LIFE);
}
function hide() { clearTimeout(timer); toast.classList.remove('is-on', 'is-hold'); }
function hold() {
if (!toast.classList.contains('is-on') || toast.classList.contains('is-hold')) return;
clearTimeout(timer); rest -= Date.now() - start; toast.classList.add('is-hold');
}
function go() {
if (!toast.classList.contains('is-on') || !toast.classList.contains('is-hold')) return;
start = Date.now(); toast.classList.remove('is-hold'); timer = setTimeout(hide, Math.max(rest, 0));
}
toast.addEventListener('mouseenter', hold);
toast.addEventListener('mouseleave', go);
toast.addEventListener('focusin', hold);
toast.addEventListener('focusout', go);
document.getElementById('pb-x').addEventListener('click', hide);
document.getElementById('pb-go').addEventListener('click', show);import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ProgressToast.module.scss";
export type ProgressToastProps = {
message?: string;
sub?: string;
holdLabel?: string;
closeLabel?: string;
openLabel?: string;
regionLabel?: string;
life?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--life"?: string };
export default function ProgressToast({
message = "Report exported",
sub = "This closes on its own in a moment",
holdLabel = "Paused",
closeLabel = "Close notification",
openLabel = "Show the notice",
regionLabel = "Self-closing notification",
life = 4200,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ProgressToastProps) {
const [demo, setDemo] = useState(true);
const [on, setOn] = useState(false);
const [hold, setHold] = useState(false);
const [run, setRun] = useState(0);
const timer = useRef<number[]>([0]);
const rest = useRef<number[]>([life, 0]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, "--life": `${life}ms` };
useEffect(() => () => { clearTimeout((timer.current ?? [0])[0]); }, []);
function later(ms: number) {
const t = timer.current ?? [0];
clearTimeout(t[0]);
t[0] = setTimeout(() => { setOn(false); setHold(false); }, Math.max(ms, 0)) as unknown as number;
}
function show() {
setDemo(false);
setOn(true);
setHold(false);
setRun((v) => v + 1); // key 를 바꿔 바가 처음부터 다시 줄게 한다
const r = rest.current ?? [life, 0];
r[0] = life; r[1] = Date.now();
later(life);
}
function pause() {
if (!on || hold) return;
const r = rest.current ?? [life, 0];
clearTimeout((timer.current ?? [0])[0]);
r[0] = r[0] - (Date.now() - r[1]);
setHold(true);
}
function resume() {
if (!on || !hold) return;
const r = rest.current ?? [life, 0];
r[1] = Date.now();
setHold(false);
later(r[0]);
}
return (
<div className={`${styles.pb} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.box}>
<div
className={`${styles.toast} ${on ? styles.isOn : ""} ${hold ? styles.isHold : ""}`}
role="region"
aria-label={regionLabel}
tabIndex={0}
onMouseEnter={pause}
onMouseLeave={resume}
onFocus={pause}
onBlur={resume}
>
<div className={styles.row}>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" /><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" /></g></svg>
</span>
<span className={styles.txt}>
<span className={styles.msg}>{message}</span>
<span className={styles.sub}>{sub}</span>
</span>
<span className={styles.hold} aria-hidden="true">
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" opacity=".2" /><path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40ZM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40Z" /></g></svg>
<span className={styles.holdtxt}>{holdLabel}</span>
</span>
<button className={styles.x} type="button" aria-label={closeLabel} onClick={() => { setOn(false); setHold(false); clearTimeout((timer.current ?? [0])[0]); }}>
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2" /><path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z" /></g></svg>
</button>
</div>
<span className={styles.track} aria-hidden="true"><span key={run} className={styles.fill} /></span>
</div>
</div>
<button className={styles.go} type="button" onClick={show}>{openLabel}</button>
</div>
);
}03 残り時間バーと一時停止 — 計測 累積面積 5.482% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · トースト通知 UI 9選 — 積み上がるスタック実装
このデモが属する記事
トースト通知 UI 9選 — 積み上がるスタック実装トースト通知 ui とは、画面の隅に一瞬だけ出て消える知らせをどう積み、どう片づけるかを決める仕組みのことです。9つともライブラリなしでバニラ JS と SCSS だけで動き、最初の1つは新しい通知が前のカードを押し上げる動きだけを扱います この記事のデモをすべて見る


