03Progress bar with hover pause
A bar at the bottom shrinks sideways to show the time left, and hovering or moving keyboard focus in freezes both the bar and the dismiss timer until you leave.
Best forAuto-dismissing notices that take a few seconds to read
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="Self-closing notification" 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">Report exported</span>
<span class="pb__sub">This closes on its own shortly</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">Paused</span>
</span>
<button class="pb__x" type="button" id="pb-x" aria-label="Close notification">
<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">Show the notice</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 Progress bar with hover pause — Measured area 5.482% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · Toast Notification UI: 9 Stacking Patterns
The post this demo lives in
Toast Notification UI: 9 Stacking PatternsToast notification UI is what decides how those corner messages pile up and how they leave again. All nine run without a See all demos in this post


