08Stagger-break snap toggle — when a secondary value hits its ceiling, the bar shatters away all at once
The instant the secondary value reaches its ceiling, that bar's opacity value spikes briefly then drops to zero and disappears.
Best forA secondary bar signaling a stagger/break state, a resource readout that vanishes at its limit
snapOpacity()SPIKE_DURDROP_DUR

<main class="stage">
<div class="gb is-demo" id="gb">
<div class="gb__head">
<span class="gb__label">Stagger</span>
<span class="gb__value" id="gbValue">0%</span>
</div>
<div class="gb__track" role="progressbar" aria-label="Stagger" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="gbTrack">
<div class="gb__fill" id="gbFill"></div>
</div>
<button type="button" class="gb__hit" id="gbPoise">Build Stagger</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html snapOpacity() 가 상한 도달 시각부터의 dt 로 튐→낙하 두 구간의
// 불투명도를 계산하고, 회복 구간은 dt/RECOVER_DUR 비율로 폭·불투명도를 함께 되돌린다.
@include stage($stage-paper);
.gb { width: 100%; max-width: 260px; background: $subject-cream; border-radius: $r-card; padding: $sp-5; box-shadow: $shadow-raised; box-sizing: border-box; }
.gb__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: $sp-3; }
.gb__label { font-size: 12px; font-weight: 700; letter-spacing: .04em; color: rgba($subject-ink, .72); text-transform: uppercase; }
.gb__value { font-size: 15px; font-weight: 700; color: $subject-ink; }
.gb__track { position: relative; height: 18px; border-radius: $r-pill; background: rgba($subject-ink, .1); overflow: hidden; }
.gb__fill {
position: absolute; left: 0; top: 0; height: 100%; width: 0%; border-radius: $r-pill;
background: $error; opacity: 1; will-change: width, opacity, transform;
}
.gb__hit {
margin-top: $sp-4; width: 100%; border: none; border-radius: $r-control;
background: $subject-blue; color: $subject-cream;
font-weight: 700; font-size: 13px; line-height: 1;
padding: $sp-3 0; cursor: pointer;
}
.gb__hit:active { box-shadow: $shadow-press; }
@media (max-width: 340px) {
.gb { max-width: 220px; padding: $sp-4; }
.gb__track { height: 15px; }
.gb__value { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.gb__fill { transform: none !important; }
}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);
}
.gb {
width: 100%;
max-width: 260px;
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.gb__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
}
.gb__label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(23, 20, 26, 0.72);
text-transform: uppercase;
}
.gb__value {
font-size: 15px;
font-weight: 700;
color: #17141a;
}
.gb__track {
position: relative;
height: 18px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
overflow: hidden;
}
.gb__fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 0%;
border-radius: 999px;
background: #c2361a;
opacity: 1;
will-change: width, opacity, transform;
}
.gb__hit {
margin-top: 16px;
width: 100%;
border: none;
border-radius: 12px;
background: #2f6df6;
color: #fff7e6;
font-weight: 700;
font-size: 13px;
line-height: 1;
padding: 12px 0;
cursor: pointer;
}
.gb__hit:active {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
@media (max-width: 340px) {
.gb {
max-width: 220px;
padding: 16px;
}
.gb__track {
height: 15px;
}
.gb__value {
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.gb__fill {
transform: none !important;
}
}(function () {
var STAGGER_MAX = 100;
var BUILD_EVENTS = [[300, 35], [1000, 30], [1700, 40]]; // ms, 자동 루프에서 기절치가 쌓이는 이벤트
var SPIKE_DUR = 130; // ms, 상한 도달 직후 불투명도가 튀는 구간
var DROP_DUR = 330; // ms, 그 뒤 0 으로 떨어지는 구간
var RECOVER_DUR = 950; // ms, 붕괴 후 다시 채워지는 구간
var PAUSE = 450;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var gb = document.getElementById('gb');
var track = document.getElementById('gbTrack');
var fill = document.getElementById('gbFill');
var valueEl = document.getElementById('gbValue');
var poiseBtn = document.getElementById('gbPoise');
var isDemo = true;
var appliedIdx = 0;
var value = 0;
var phase = 'build'; // build → snap → recover
var phaseStart = 0;
var cycleStart = 0;
var hitIdx = 0;
function snapOpacity(dt) {
// 계산 겹: 상한에 닿는 순간 불투명도가 짧게 튀었다가(스파이크) 감쇠 없이 선형으로 0까지 떨어진다
if (dt < SPIKE_DUR) return 1;
if (dt < SPIKE_DUR + DROP_DUR) return 1 - (dt - SPIKE_DUR) / DROP_DUR;
return 0;
}
function render(ts) {
var dtPhase = ts - phaseStart;
var opacity = 1, width = value, shake = 0;
if (phase === 'snap') {
opacity = snapOpacity(dtPhase);
shake = dtPhase < SPIKE_DUR ? Math.sin(dtPhase * .9) * 4 : 0;
width = STAGGER_MAX;
} else if (phase === 'recover') {
var r = Math.min(1, dtPhase / RECOVER_DUR);
opacity = r;
width = r * STAGGER_MAX * 0.9; // 계산 겹: 회복은 상한 근처(90%)까지만, 다음 타격이 마저 채운다
value = width;
}
fill.style.width = Math.max(0, Math.min(100, width)).toFixed(2) + '%';
fill.style.opacity = Math.max(0, Math.min(1, opacity)).toFixed(3);
fill.style.transform = 'translateX(' + shake.toFixed(2) + 'px)';
var shown = Math.round(Math.max(0, Math.min(100, width)));
track.setAttribute('aria-valuenow', String(shown));
valueEl.textContent = shown + '%';
}
function triggerSnap(ts) {
phase = 'snap';
phaseStart = ts;
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
poiseBtn.addEventListener('click', function () {
human();
var now = performance.now();
if (phase === 'build') {
value = Math.min(STAGGER_MAX, value + [35, 30, 40, 35][hitIdx % 4]);
hitIdx++;
if (value >= STAGGER_MAX) triggerSnap(now);
} else if (phase === 'snap') {
// 스냅 도중엔 무시 — 붕괴가 끝나야 다시 쌓인다
} else {
phase = 'build'; value = 0; hitIdx = 0;
}
});
poiseBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
poiseBtn.click();
});
function loop(ts) {
if (!cycleStart) { cycleStart = ts; phaseStart = ts; }
if (isDemo) {
var elapsed = ts - cycleStart;
if (phase === 'build') {
while (appliedIdx < BUILD_EVENTS.length && elapsed >= BUILD_EVENTS[appliedIdx][0]) {
value = Math.min(STAGGER_MAX, value + BUILD_EVENTS[appliedIdx][1]);
appliedIdx++;
if (value >= STAGGER_MAX) triggerSnap(ts);
}
} else if (phase === 'snap' && ts - phaseStart >= SPIKE_DUR + DROP_DUR) {
phase = 'recover'; phaseStart = ts;
} else if (phase === 'recover' && ts - phaseStart >= RECOVER_DUR + PAUSE) {
cycleStart = ts; phaseStart = ts; phase = 'build'; appliedIdx = 0; value = 0;
}
}
render(ts);
requestAnimationFrame(loop);
}
if (!reduce) {
requestAnimationFrame(loop);
} else {
render(0);
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './GaugeBarStaggerBreakSnap.module.scss';
type Box<T> = { current: T };
const STAGGER_MAX = 100;
const BUILD_EVENTS: [number, number][] = [[300, 35], [1000, 30], [1700, 40]]; // ms, 자동 루프에서 기절치가 쌓이는 이벤트
const SPIKE_DUR = 130; // ms, 상한 도달 직후 불투명도가 튀는 구간
const DROP_DUR = 330; // ms, 그 뒤 0 으로 떨어지는 구간
const RECOVER_DUR = 950; // ms, 붕괴 후 다시 채워지는 구간
const PAUSE = 450;
function snapOpacity(dt: number) {
// 계산 겹: 상한에 닿는 순간 불투명도가 짧게 튀었다가(스파이크) 감쇠 없이 선형으로 0까지 떨어진다
if (dt < SPIKE_DUR) return 1;
if (dt < SPIKE_DUR + DROP_DUR) return 1 - (dt - SPIKE_DUR) / DROP_DUR;
return 0;
}
export default function GaugeBarStaggerBreakSnap() {
const fillRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackRef = useRef(null) as unknown as Box<HTMLDivElement>;
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const [isDemo, setIsDemo] = useState(true);
const [pct, setPct] = useState(0);
const valueRef = useRef(0) as unknown as Box<number>;
const phaseRef = useRef<'build' | 'snap' | 'recover'>('build') as unknown as Box<'build' | 'snap' | 'recover'>;
const phaseStartRef = useRef(0) as unknown as Box<number>;
const hitIdxRef = useRef(0) as unknown as Box<number>;
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
function render(ts: number) {
const dtPhase = ts - phaseStartRef.current;
let opacity = 1;
let width = valueRef.current;
let shake = 0;
if (phaseRef.current === 'snap') {
opacity = snapOpacity(dtPhase);
shake = dtPhase < SPIKE_DUR ? Math.sin(dtPhase * 0.9) * 4 : 0;
width = STAGGER_MAX;
} else if (phaseRef.current === 'recover') {
const r = Math.min(1, dtPhase / RECOVER_DUR);
opacity = r;
width = r * STAGGER_MAX * 0.9; // 계산 겹: 회복은 상한 근처(90%)까지만, 다음 타격이 마저 채운다
valueRef.current = width;
}
const fill = fillRef.current;
const track = trackRef.current;
const clampedWidth = Math.max(0, Math.min(100, width));
if (fill) {
fill.style.width = clampedWidth.toFixed(2) + '%';
fill.style.opacity = Math.max(0, Math.min(1, opacity)).toFixed(3);
fill.style.transform = 'translateX(' + shake.toFixed(2) + 'px)';
}
const shown = Math.round(clampedWidth);
if (track) track.setAttribute('aria-valuenow', String(shown));
setPct(shown);
}
function triggerSnap(ts: number) {
phaseRef.current = 'snap';
phaseStartRef.current = ts;
}
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let cycleStart = 0;
let appliedIdx = 0;
let rafId = 0;
function loop(ts: number) {
if (!cycleStart) { cycleStart = ts; phaseStartRef.current = ts; }
if (isDemoRef.current) {
const elapsed = ts - cycleStart;
if (phaseRef.current === 'build') {
while (appliedIdx < BUILD_EVENTS.length && elapsed >= BUILD_EVENTS[appliedIdx][0]) {
valueRef.current = Math.min(STAGGER_MAX, valueRef.current + BUILD_EVENTS[appliedIdx][1]);
appliedIdx++;
if (valueRef.current >= STAGGER_MAX) triggerSnap(ts);
}
} else if (phaseRef.current === 'snap' && ts - phaseStartRef.current >= SPIKE_DUR + DROP_DUR) {
phaseRef.current = 'recover'; phaseStartRef.current = ts;
} else if (phaseRef.current === 'recover' && ts - phaseStartRef.current >= RECOVER_DUR + PAUSE) {
cycleStart = ts; phaseStartRef.current = ts; phaseRef.current = 'build'; appliedIdx = 0; valueRef.current = 0;
}
}
render(ts);
rafId = requestAnimationFrame(loop);
}
if (!reduce) {
rafId = requestAnimationFrame(loop);
} else {
render(0);
}
return () => cancelAnimationFrame(rafId);
}, []);
function onPoise() {
human();
const now = performance.now();
if (phaseRef.current === 'build') {
valueRef.current = Math.min(STAGGER_MAX, valueRef.current + [35, 30, 40, 35][hitIdxRef.current % 4]);
hitIdxRef.current++;
if (valueRef.current >= STAGGER_MAX) triggerSnap(now);
} else if (phaseRef.current === 'snap') {
// 스냅 도중엔 무시 — 붕괴가 끝나야 다시 쌓인다
} else {
phaseRef.current = 'build'; valueRef.current = 0; hitIdxRef.current = 0;
}
}
function onKeyDown(e: { key: string; preventDefault: () => void }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
onPoise();
}
return (
<div className={`${styles.gb} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.gbHead}>
<span className={styles.gbLabel}>기절치</span>
<span className={styles.gbValue}>{pct}%</span>
</div>
<div
className={styles.gbTrack}
role="progressbar"
aria-label="기절치"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
ref={trackRef}
>
<div className={styles.gbFill} ref={fillRef} />
</div>
<button type="button" className={styles.gbHit} onClick={onPoise} onKeyDown={onKeyDown}>
기절치 쌓기
</button>
</div>
);
}08 Stagger-break snap toggle — when a secondary value hits its ceiling, the bar shatters away all at once — Measured area 2.878% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · CSS Progress Bar: 9 Game-Style Fill Patterns
The post this demo lives in
CSS Progress Bar: 9 Game-Style Fill PatternsCSS Progress Bar: One of nine narrows as its value drops while a red glow trails behind, one layers excess past its ceil See all demos in this post
If you like this effect - similar demos
Loaders08 Row stagger reveal — an effect where each list row appears following an order value once the refresh finishesPull to Refresh CSS: 9 Loading Patterns
Loaders08 Mute toggle — a switch interpolating opacity by its state value9 Typing Indicator CSS Patterns (Chat UI)
Loaders06 End-of-list reveal — a message appearing once the remaining-item value reaches zeroInfinite Scroll Loading: 9 Essential UI Patterns