08기절 게이지 붕괴 전환 — 두 번째 값이 상한에 닿으면 막대가 한 번에 부서지듯 사라지는 방식
보조 값이 상한에 닿는 순간 그 막대의 불투명도 값이 짧게 튀었다가 0으로 떨어지며 사라집니다.
쓰임기절·붕괴 상태를 알리는 보조 막대, 한계에 닿으면 사라지는 자원 표시
snapOpacity()SPIKE_DURDROP_DUR

<main class="stage">
<div class="gb is-demo" id="gb">
<div class="gb__head">
<span class="gb__label">기절치</span>
<span class="gb__value" id="gbValue">0%</span>
</div>
<div class="gb__track" role="progressbar" aria-label="기절치" 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">기절치 쌓기</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 기절 게이지 붕괴 전환 — 두 번째 값이 상한에 닿으면 막대가 한 번에 부서지듯 사라지는 방식 — 측정 누적면적 2.878% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 게이지 바 9종 — 게임처럼 채워지는 아홉 가지 방식
이 데모가 속한 글
게이지 바 9종 — 게임처럼 채워지는 아홉 가지 방식게이지 바는 값이 오르내릴 때마다 채워진 폭이 바뀌는 막대인데, 아홉 개 중 하나는 값이 줄면 막대가 먼저 좁아지고 그 뒤로 붉은 잔상이 서서히 따라 줄어들며, 하나는 값이 상한을 넘으면 초과분이 반투명한 겹으로 얹 이 글의 데모 전부 보기


