01Damage-flash deplete toggle — when a value drops, the bar narrows first while a red afterglow trails behind, narrowing more slowly
The moment the value drops, the bar's width narrows first, and the leftover red width behind it shrinks more slowly to match.
Best forA combat screen's health readout, a status bar that emphasizes sudden damage
applyHit()LERP_RATEFLASH_TAU

<main class="stage">
<div class="gb is-demo" id="gb">
<div class="gb__head">
<span class="gb__label">HP</span>
<span class="gb__value" id="gbValue">100 / 100</span>
</div>
<div class="gb__track" role="progressbar" aria-label="HP" aria-valuemin="0" aria-valuemax="100" aria-valuenow="100" id="gbTrack">
<div class="gb__trail" id="gbTrail"></div>
<div class="gb__fill" id="gbFill"></div>
<div class="gb__flash" id="gbFlash"></div>
</div>
<button type="button" class="gb__hit" id="gbHit">Take Damage</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html loop() 이 매 프레임 잔상(trail) 값을 지수 평활로 본값에 수렴시키고
// 플래시는 감쇠 지수함수로 사그라든다 — 키프레임 반복이 아니라 시간 t 의 계산값이다.
@include stage($stage-ink);
.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__trail, .gb__fill {
position: absolute; left: 0; top: 0; height: 100%; border-radius: $r-pill;
will-change: width;
}
.gb__trail { background: $error; width: 100%; }
.gb__fill { background: $subject-blue-deep; width: 100%; z-index: 1; }
.gb__flash {
position: absolute; inset: 0; border-radius: $r-pill;
background: $subject-cream; opacity: 0; z-index: 2; pointer-events: none;
will-change: opacity;
}
.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__flash { display: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
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__trail, .gb__fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
border-radius: 999px;
will-change: width;
}
.gb__trail {
background: #c2361a;
width: 100%;
}
.gb__fill {
background: #0a4fe8;
width: 100%;
z-index: 1;
}
.gb__flash {
position: absolute;
inset: 0;
border-radius: 999px;
background: #fff7e6;
opacity: 0;
z-index: 2;
pointer-events: none;
will-change: opacity;
}
.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__flash {
display: none;
}
}(function () {
var HP_MAX = 100;
// 계산 겹 상수: 시드 고정 전투 이벤트 배열 — [발생시각ms, 피해량]
var EVENTS = [[300, 24], [1100, 18], [2000, 28], [2900, 12]];
var CYCLE = 3800; // ms, 자동 루프 한 바퀴
var LERP_RATE = 0.006; // 계산 겹: 잔상이 본값을 따라가는 지수 평활 상수(프레임 독립)
var FLASH_TAU = 70; // ms, 플래시 감쇠 시간상수
var HIT_LIST = [24, 18, 28, 12, 20]; // 클릭(실사용자) 피해량 순환
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 trailEl = document.getElementById('gbTrail');
var flashEl = document.getElementById('gbFlash');
var valueEl = document.getElementById('gbValue');
var hitBtn = document.getElementById('gbHit');
var isDemo = true;
var mainHP = HP_MAX;
var trailHP = HP_MAX;
var flashStart = -9999;
var appliedIdx = 0;
var cycleStart = 0;
var lastTs = 0;
var hitIdx = 0;
function render() {
var mp = Math.max(0, Math.min(100, mainHP));
var tp = Math.max(0, Math.min(100, trailHP));
fill.style.width = mp.toFixed(2) + '%';
trailEl.style.width = tp.toFixed(2) + '%';
track.setAttribute('aria-valuenow', String(Math.round(mp)));
valueEl.textContent = Math.round(mp) + ' / ' + HP_MAX;
}
function applyHit(dmg, ts) {
trailHP = mainHP; // 잔상은 피해 직전 값에서 출발해 뒤늦게 따라간다
mainHP = Math.max(0, mainHP - dmg);
flashStart = ts;
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
function onHit() {
human();
var now = performance.now();
if (mainHP <= 0) {
mainHP = HP_MAX; trailHP = HP_MAX; flashStart = -9999;
} else {
applyHit(HIT_LIST[hitIdx % HIT_LIST.length], now);
hitIdx++;
}
render();
}
hitBtn.addEventListener('click', onHit);
hitBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
onHit();
});
function loop(ts) {
if (!cycleStart) { cycleStart = ts; lastTs = ts; }
var dt = ts - lastTs;
lastTs = ts;
var elapsed = ts - cycleStart;
if (isDemo) {
if (elapsed >= CYCLE) {
cycleStart = ts;
elapsed = 0;
appliedIdx = 0;
mainHP = HP_MAX;
trailHP = HP_MAX;
flashStart = -9999;
}
while (appliedIdx < EVENTS.length && elapsed >= EVENTS[appliedIdx][0]) {
applyHit(EVENTS[appliedIdx][1], ts);
appliedIdx++;
}
}
// 계산 겹: 잔상 지수 평활 — dt(ms) 에 독립적으로 같은 반감기를 유지
trailHP += (mainHP - trailHP) * (1 - Math.exp(-LERP_RATE * dt));
var flash = Math.max(0, Math.exp(-(ts - flashStart) / FLASH_TAU));
flashEl.style.opacity = flash < 0.02 ? '0' : flash.toFixed(3);
render();
requestAnimationFrame(loop);
}
if (!reduce) {
requestAnimationFrame(loop);
} else {
render();
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './GaugeBarDamageFlash.module.scss';
type Box<T> = { current: T };
const HP_MAX = 100;
// 계산 겹 상수: 시드 고정 전투 이벤트 배열 — [발생시각ms, 피해량]
const EVENTS: [number, number][] = [[300, 24], [1100, 18], [2000, 28], [2900, 12]];
const CYCLE = 3800; // ms, 자동 루프 한 바퀴
const LERP_RATE = 0.006; // 계산 겹: 잔상이 본값을 따라가는 지수 평활 상수(프레임 독립)
const FLASH_TAU = 70; // ms, 플래시 감쇠 시간상수
const HIT_LIST = [24, 18, 28, 12, 20]; // 클릭(실사용자) 피해량 순환
export default function GaugeBarDamageFlash() {
const fillRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trailRef = useRef(null) as unknown as Box<HTMLDivElement>;
const flashRef = 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 [value, setValue] = useState(HP_MAX);
const mainHPRef = useRef(HP_MAX) as unknown as Box<number>;
const trailHPRef = useRef(HP_MAX) as unknown as Box<number>;
const flashStartRef = useRef(-9999) as unknown as Box<number>;
const hitIdxRef = useRef(0) as unknown as Box<number>;
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
function applyHit(dmg: number, ts: number) {
trailHPRef.current = mainHPRef.current; // 잔상은 피해 직전 값에서 출발해 뒤늦게 따라간다
mainHPRef.current = Math.max(0, mainHPRef.current - dmg);
flashStartRef.current = ts;
}
function render() {
const mp = Math.max(0, Math.min(100, mainHPRef.current));
const tp = Math.max(0, Math.min(100, trailHPRef.current));
const fill = fillRef.current;
const trail = trailRef.current;
const track = trackRef.current;
if (fill) fill.style.width = mp.toFixed(2) + '%';
if (trail) trail.style.width = tp.toFixed(2) + '%';
if (track) track.setAttribute('aria-valuenow', String(Math.round(mp)));
setValue(Math.round(mp));
}
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let cycleStart = 0;
let lastTs = 0;
let appliedIdx = 0;
let rafId = 0;
function loop(ts: number) {
if (!cycleStart) { cycleStart = ts; lastTs = ts; }
const dt = ts - lastTs;
lastTs = ts;
let elapsed = ts - cycleStart;
if (isDemoRef.current) {
if (elapsed >= CYCLE) {
cycleStart = ts;
elapsed = 0;
appliedIdx = 0;
mainHPRef.current = HP_MAX;
trailHPRef.current = HP_MAX;
flashStartRef.current = -9999;
}
while (appliedIdx < EVENTS.length && elapsed >= EVENTS[appliedIdx][0]) {
applyHit(EVENTS[appliedIdx][1], ts);
appliedIdx++;
}
}
// 계산 겹: 잔상 지수 평활 — dt(ms) 에 독립적으로 같은 반감기를 유지
trailHPRef.current += (mainHPRef.current - trailHPRef.current) * (1 - Math.exp(-LERP_RATE * dt));
const flash = Math.max(0, Math.exp(-(ts - flashStartRef.current) / FLASH_TAU));
const flashEl = flashRef.current;
if (flashEl) flashEl.style.opacity = flash < 0.02 ? '0' : flash.toFixed(3);
render();
rafId = requestAnimationFrame(loop);
}
if (!reduce) {
rafId = requestAnimationFrame(loop);
} else {
render();
}
return () => cancelAnimationFrame(rafId);
}, []);
function onHit() {
human();
const now = performance.now();
if (mainHPRef.current <= 0) {
mainHPRef.current = HP_MAX;
trailHPRef.current = HP_MAX;
flashStartRef.current = -9999;
} else {
applyHit(HIT_LIST[hitIdxRef.current % HIT_LIST.length], now);
hitIdxRef.current++;
}
render();
}
function onKeyDown(e: { key: string; preventDefault: () => void }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
onHit();
}
return (
<div className={`${styles.gb} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.gbHead}>
<span className={styles.gbLabel}>HP</span>
<span className={styles.gbValue}>{value} / {HP_MAX}</span>
</div>
<div
className={styles.gbTrack}
role="progressbar"
aria-label="HP"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={value}
ref={trackRef}
>
<div className={styles.gbTrail} ref={trailRef} />
<div className={styles.gbFill} ref={fillRef} />
<div className={styles.gbFlash} ref={flashRef} />
</div>
<button type="button" className={styles.gbHit} onClick={onHit} onKeyDown={onKeyDown}>
피해 입기
</button>
</div>
);
}01 Damage-flash deplete toggle — when a value drops, the bar narrows first while a red afterglow trails behind, narrowing more slowly — Measured area 2.853% · moving frames 21/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
Loaders05 Extraction progress and form values changing together — while the extraction progress value fills, a form value changes along with itDownload Progress Bar: 9 Loading UI Patterns
Loaders03 QR Code Whose Pulse Ring Narrows by the Time Left ValueQR Code Loading Animation — 9 CSS Expiry Patterns
Loaders01 Linear fill9 CSS Progress Bar Circle Gauges, Copy-Paste Ready