css プログレスバー 9選 — ゲーム風に満ちる9つの方式
css プログレスバー:9つのうち1つは値が減ると帯が先に狭まり赤い残光が後を追って縮み、1つは値が上限を超えるとその超過分が半透明の層として重なり、もう1つは補助値が上限に達すると帯の不透明度の値が跳ねてから消えます。
01 game-gauge-bar-damage-flash-deplete
02 game-gauge-bar-regen-tick-glow
03 game-gauge-bar-segment-pip-crack
04 game-gauge-bar-overheal-shield-cap
05 game-gauge-bar-critical-threshold-pulse
06 game-gauge-bar-charge-hold-release
07 game-gauge-bar-multi-lane-compare
08 game-gauge-bar-stagger-break-snap
09 game-gauge-bar-reduced-motion-instant
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 被弾フラッシュ減少切替 — 値が減ると帯がすぐ縮み、その後を赤い残光がゆっくり追って狭まる方式
- 02 回復ティック発光切替 — 値が一定間隔で少しずつ上がるたび帯の先端が光る方式
- 03 区切りピップ亀裂切替 — 帯が複数のピップに分かれ、値が1ピップ下がるとそのピップに亀裂模様が残る方式
- 04 過剰回復シールド重ね切替 — 値が上限を超えるとその超過分が半透明の層として帯の上に重なる方式
- 05 危険域拍動切替 — 値が定めた低い区間に入ると帯全体が一定のリズムで明滅する方式
- 06 チャージ保持放出切替 — 押している間に値がゆっくり満ち、離した瞬間に一度に放出される方式
- 07 複数レーン比較切替 — 並んだ2本以上の帯がそれぞれ異なる値で並行して満ちる方式
- 08 スタン崩壊切替 — 補助値が上限に達すると帯が一気に崩れて消える方式
- 09 モーション削減即時切替 — モーション削減設定がオンだと前の8方式のアニメーションが全て即座に値へ変わる方式
これら9つのデモはいずれもサーバーと通信せず、localStorage にも触れず、Math.random も一度も呼びません。それぞれが1つの数値状態と少数の定数だけで、値の増減がどう見えるかを決める小さなHPバー・リソースバーの実装です。この狙いの狭さのおかげで、どの値が変わるのか——体力・上限・補助のスタガー値のどれか——を先に確認すれば、コードを開く前にパターンの名前が分かります。「css プログレスバー デザイン」で検索して出てくる記事の多くは色や角丸といった見た目だけを比べますが、この記事はゲーム内の状態イベントが帯の意味そのものを変える9つに絞っています。
9つのパターンの解剖 — どの値がいつ変わるか
9つとも自前の requestAnimationFrame ループを持ち、どれも Math.random を一度も呼びません。07番の4本のレーンは固定のサイン波(LANES が各レーンの freq と phase を持つ)で動き、残りは固定の EVENTS・BUILD_EVENTS・JOLTS 配列を順に踏むため、何度開いても同じ再生になります。9つのうち6つはCSSトランジション任せではなく、フレームごとに本当の減衰や振動を計算します。01番の trailHP は指数的な LERP_RATE で現在値へ遅れて近づき、02番のエッジの発光と05番の明るさの拍動はどちらも Math.exp ベースの減衰(GLOW_TAU と PERIOD_MIN/PERIOD_MAX)で落ち着き、04番の超過分は DECAY_RATE で上限の下へ戻り、06番の burstAt() は指数減衰にコサインを掛けて放出スプリングの RELEASE_ZETA を形にし、08番の snapOpacity() は SPIKE_DUR の間だけ跳ねてから DROP_DUR で線形に0へ落ち、その後は別枠の RECOVER_DUR が帯を再び満たします。残り3つは違う仕組みで収まります。03番の stateFor() は固定の CRACK_DUR/STAGGER の窓でピップの亀裂と塗りを進めるだけで継続的な減衰はなく、07番は valuesAt() が毎フレーム各レーンのサイン波を読み続けるため訪問者が何もしなくても止まらず動き、09番の右側の帯はフレームごとの計算を一切せず目標値へ中間段階なしにスナップします。リスナーはどれも実際の操作にのみ答え、自動再生からのイベントは0件です。01〜05・07・08・09番の8つは click に Enter・Space 用の keydown を組み合わせ、06番だけが例外で pointerdown・pointerup・pointerleave・pointercancel に keydown・keyup まで加えて、押し続ける操作とキーボードの等価な操作の両方を同じ扱いで受け付けます。9つとも isDemo の自動再生状態から始まり、最初のクリックかキー入力が human() を呼ぶまで自前の配列を再生し続け、human() は is-demo クラスを外してその自動再生を完全に止めます — 09番だけが例外で、左右を並べて見せることが項目の目的そのものなので、訪問者が操作を始めた後も左側のループは requestAnimationFrame の上で回り続けます。
パターン別コード
01被弾フラッシュ減少切替 — 値が減ると帯がすぐ縮み、その後を赤い残光がゆっくり追って狭まる方式
値が減った瞬間に帯の幅が先に狭まり、その差分の赤い幅は遅れてゆっくり縮んでいきます。戦闘画面のHP表示や、瞬間的な被害を強調したい状態バーに向く方式で、赤い残光そのものが直前にどれだけ減ったかを見せる記録になります。

<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">ダメージを受ける</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 · 被弾フラッシュ減少切替 — 値が減ると帯がすぐ縮み、その後を赤い残光がゆっくり追って狭まる方式 — 計測 累積面積 2.853% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
数値が語ること
01番は累積面積2.85%・平均強度97.7を23サンプル中21フレームで示し、applyHit() が被弾の瞬間に残光の値を被弾直前の値へ合わせたあとは、毎フレーム LERP_RATE が新しい値へ指数的に近づけ、FLASH_TAU が赤いフラッシュを減衰させています。
02回復ティック発光切替 — 値が一定間隔で少しずつ上がるたび帯の先端が光る方式
値が短い間隔で一段ずつ上がるたび、帯の先端の明るさの値が一瞬強くなってから落ち着きます。自動回復中の体力・気力表示や、待機中に少しずつ満ちていくリソースバーに向く方式で、一段ごとの光がティックそのものを目に見える出来事にします。

<main class="stage">
<div class="gb is-demo" id="gb">
<div class="gb__head">
<span class="gb__label">MP</span>
<span class="gb__value" id="gbValue">30 / 100</span>
</div>
<div class="gb__track" role="progressbar" aria-label="MP" aria-valuemin="0" aria-valuemax="100" aria-valuenow="30" id="gbTrack">
<div class="gb__fill" id="gbFill"></div>
<div class="gb__edge" id="gbEdge"></div>
</div>
<button type="button" class="gb__hit" id="gbTick">回復ティック追加</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html tick() 이 고정 간격마다 값을 올리고, loop() 은 매 프레임
// 글로우 값을 exp(-dt/GLOW_TAU) 감쇠 공식으로 다시 계산해 밝기·번짐 두 속성에 동시에 먹인다.
@include stage($stage-yellow);
.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: visible; }
.gb__fill { position: absolute; left: 0; top: 0; height: 100%; width: 30%; border-radius: $r-pill; background: $subject-mint; will-change: width; }
.gb__edge {
position: absolute; top: 50%; left: 30%;
width: 14px; height: 14px; border-radius: 50%;
background: $subject-mint; transform: translate(-50%, -50%) scale(1);
box-shadow: 0 0 10px 4px rgba($subject-mint, .8);
opacity: .35; will-change: 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__edge { display: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
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: visible;
}
.gb__fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 30%;
border-radius: 999px;
background: #4cd4a6;
will-change: width;
}
.gb__edge {
position: absolute;
top: 50%;
left: 30%;
width: 14px;
height: 14px;
border-radius: 50%;
background: #4cd4a6;
transform: translate(-50%, -50%) scale(1);
box-shadow: 0 0 10px 4px rgba(76, 212, 166, 0.8);
opacity: 0.35;
will-change: 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__edge {
display: none;
}
}(function () {
var RES_MAX = 100;
var START = 30; // 계산 겹 상수: 회복 시작값
var TICK_INTERVAL = 340; // ms, 자동 루프의 틱 간격
var TICK_AMOUNT = 7; // 틱마다 오르는 값
var GLOW_TAU = 150; // ms, 글로우 감쇠 시간상수
var PAUSE = 500; // ms, 가득 찬 뒤 다음 루프 전 대기
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 edge = document.getElementById('gbEdge');
var valueEl = document.getElementById('gbValue');
var tickBtn = document.getElementById('gbTick');
var TICK_COUNT = Math.ceil((RES_MAX - START) / TICK_AMOUNT); // 계산 겹: 간격에서 파생
var CYCLE = TICK_COUNT * TICK_INTERVAL + PAUSE;
var isDemo = true;
var value = START;
var glowStart = -9999;
var cycleStart = 0;
var lastTickIdx = -1;
function render(glow) {
var v = Math.max(0, Math.min(RES_MAX, value));
fill.style.width = v.toFixed(2) + '%';
edge.style.left = v.toFixed(2) + '%';
var g = glow < 0.02 ? 0 : glow;
// 계산 겹: 글로우 값이 밝기·번짐 두 CSS 값을 동시에 구동한다
edge.style.opacity = (0.35 + g * 0.65).toFixed(3);
edge.style.transform = 'translate(-50%, -50%) scale(' + (1 + g * 0.9).toFixed(3) + ')';
track.setAttribute('aria-valuenow', String(Math.round(v)));
valueEl.textContent = Math.round(v) + ' / ' + RES_MAX;
}
function tick(ts) {
value = Math.min(RES_MAX, value + TICK_AMOUNT);
glowStart = ts;
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
tickBtn.addEventListener('click', function () {
human();
var now = performance.now();
if (value >= RES_MAX) { value = START; }
else { tick(now); }
render(1);
});
tickBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
tickBtn.click();
});
function loop(ts) {
if (!cycleStart) cycleStart = ts;
var elapsed = ts - cycleStart;
if (isDemo) {
if (elapsed >= CYCLE) {
cycleStart = ts; elapsed = 0; value = START; lastTickIdx = -1;
}
var idx = Math.floor(elapsed / TICK_INTERVAL);
if (idx > lastTickIdx && idx < TICK_COUNT) {
lastTickIdx = idx;
tick(ts);
}
}
var glow = Math.max(0, Math.exp(-(ts - glowStart) / GLOW_TAU));
render(glow);
requestAnimationFrame(loop);
}
if (!reduce) {
requestAnimationFrame(loop);
} else {
render(0);
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './GaugeBarRegenTickGlow.module.scss';
type Box<T> = { current: T };
const RES_MAX = 100;
const START = 30; // 계산 겹 상수: 회복 시작값
const TICK_INTERVAL = 340; // ms, 자동 루프의 틱 간격
const TICK_AMOUNT = 7; // 틱마다 오르는 값
const GLOW_TAU = 150; // ms, 글로우 감쇠 시간상수
const PAUSE = 500; // ms, 가득 찬 뒤 다음 루프 전 대기
const TICK_COUNT = Math.ceil((RES_MAX - START) / TICK_AMOUNT); // 계산 겹: 간격에서 파생
const CYCLE = TICK_COUNT * TICK_INTERVAL + PAUSE;
export default function GaugeBarRegenTickGlow() {
const fillRef = useRef(null) as unknown as Box<HTMLDivElement>;
const edgeRef = 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(START);
const valueRef = useRef(START) as unknown as Box<number>;
const glowStartRef = useRef(-9999) as unknown as Box<number>;
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
function tick(ts: number) {
valueRef.current = Math.min(RES_MAX, valueRef.current + TICK_AMOUNT);
glowStartRef.current = ts;
}
function render(glow: number) {
const v = Math.max(0, Math.min(RES_MAX, valueRef.current));
const fill = fillRef.current;
const edge = edgeRef.current;
const track = trackRef.current;
const g = glow < 0.02 ? 0 : glow;
if (fill) fill.style.width = v.toFixed(2) + '%';
if (edge) {
edge.style.left = v.toFixed(2) + '%';
// 계산 겹: 글로우 값이 밝기·번짐 두 CSS 값을 동시에 구동한다
edge.style.opacity = (0.35 + g * 0.65).toFixed(3);
edge.style.transform = 'translate(-50%, -50%) scale(' + (1 + g * 0.9).toFixed(3) + ')';
}
if (track) track.setAttribute('aria-valuenow', String(Math.round(v)));
setValue(Math.round(v));
}
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let cycleStart = 0;
let lastTickIdx = -1;
let rafId = 0;
function loop(ts: number) {
if (!cycleStart) cycleStart = ts;
let elapsed = ts - cycleStart;
if (isDemoRef.current) {
if (elapsed >= CYCLE) {
cycleStart = ts; elapsed = 0; valueRef.current = START; lastTickIdx = -1;
}
const idx = Math.floor(elapsed / TICK_INTERVAL);
if (idx > lastTickIdx && idx < TICK_COUNT) {
lastTickIdx = idx;
tick(ts);
}
}
const glow = Math.max(0, Math.exp(-(ts - glowStartRef.current) / GLOW_TAU));
render(glow);
rafId = requestAnimationFrame(loop);
}
if (!reduce) {
rafId = requestAnimationFrame(loop);
} else {
render(0);
}
return () => cancelAnimationFrame(rafId);
}, []);
function onTick() {
human();
if (valueRef.current >= RES_MAX) { valueRef.current = START; }
else { tick(performance.now()); }
render(1);
}
function onKeyDown(e: { key: string; preventDefault: () => void }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
onTick();
}
return (
<div className={`${styles.gb} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.gbHead}>
<span className={styles.gbLabel}>MP</span>
<span className={styles.gbValue}>{value} / {RES_MAX}</span>
</div>
<div
className={styles.gbTrack}
role="progressbar"
aria-label="MP"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={value}
ref={trackRef}
>
<div className={styles.gbFill} ref={fillRef} />
<div className={styles.gbEdge} ref={edgeRef} />
</div>
<button type="button" className={styles.gbHit} onClick={onTick} onKeyDown={onKeyDown}>
회복 틱 넣기
</button>
</div>
);
}02 · 回復ティック発光切替 — 値が一定間隔で少しずつ上がるたび帯の先端が光る方式 — 計測 累積面積 5.433% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function render(glow) {
var v = Math.max(0, Math.min(RES_MAX, value));
fill.style.width = v.toFixed(2) + '%';
edge.style.left = v.toFixed(2) + '%';
var g = glow < 0.02 ? 0 : glow;
// 計算層: グロー値が明るさとにじみの2つのCSS値を同時に駆動する
edge.style.opacity = (0.35 + g * 0.65).toFixed(3);
edge.style.transform = 'translate(-50%, -50%) scale(' + (1 + g * 0.9).toFixed(3) + ')';
track.setAttribute('aria-valuenow', String(Math.round(v)));
valueEl.textContent = Math.round(v) + ' / ' + RES_MAX;
}
function tick(ts) {
value = Math.min(RES_MAX, value + TICK_AMOUNT);
glowStart = ts;
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
数値が語ること
02番は累積面積5.43%・平均強度39.9を23サンプル中23フレーム全部で示し、9つの中で最も動いたフレーム数となっていますが、これは値そのものが固定の TICK_INTERVAL ごとにしか上がらない一方で GLOW_TAU の減衰がその合間も毎フレーム先端の明るさと拡大率を再計算し続けているためです。
03区切りピップ亀裂切替 — 帯が複数のピップに分かれ、値が1ピップ下がるとそのピップに亀裂模様が残る方式
値が1ピップ分下がった瞬間、そのピップの亀裂度の値が大きくなり、以降のピップは塗りを失います。ボス体力の段階区間表示や、残機をピップで見せる表示に向く方式で、割れたピップがそのまま被弾の履歴として画面に残ります。

<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">5 / 5</span>
</div>
<div class="gb__track" id="gbTrack" role="progressbar" aria-label="ボスHP" aria-valuemin="0" aria-valuemax="5" aria-valuenow="5"></div>
<button type="button" class="gb__hit" id="gbBreak">セグメント破壊</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html stateFor() 가 각 칸이 깨진 시각과 현재 시각의 차(dt)로
// 균열 두 줄의 등장 시점(stagger)과 채움 투명도를 매 프레임 다시 계산한다.
@include stage($stage-orange);
.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 { display: grid; grid-template-columns: repeat(5, 1fr); gap: $sp-1; height: 22px; }
.gb__pip { position: relative; border-radius: $r-xs; background: rgba($subject-ink, .12); overflow: hidden; }
.gb__pipFill { position: absolute; inset: 0; background: $subject-blue-deep; opacity: 1; will-change: opacity; }
.gb__crack {
position: absolute; left: 50%; top: 50%; width: 2px; height: 70%;
background: $subject-cream; opacity: 0; will-change: opacity;
}
.gb__crack--a { transform: translate(-50%, -50%) rotate(24deg); }
.gb__crack--b { transform: translate(-50%, -50%) rotate(-28deg); }
.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: 18px; }
.gb__value { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.gb__crack { display: 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);
}
.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 {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
height: 22px;
}
.gb__pip {
position: relative;
border-radius: 4px;
background: rgba(23, 20, 26, 0.12);
overflow: hidden;
}
.gb__pipFill {
position: absolute;
inset: 0;
background: #0a4fe8;
opacity: 1;
will-change: opacity;
}
.gb__crack {
position: absolute;
left: 50%;
top: 50%;
width: 2px;
height: 70%;
background: #fff7e6;
opacity: 0;
will-change: opacity;
}
.gb__crack--a {
transform: translate(-50%, -50%) rotate(24deg);
}
.gb__crack--b {
transform: translate(-50%, -50%) rotate(-28deg);
}
.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: 18px;
}
.gb__value {
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.gb__crack {
display: none;
}
}(function () {
var PIPS = 5; // 계산 겹 상수: 칸 개수
var EVENT_T = [400, 1300, 2200, 3100]; // ms, 자동 루프 안에서 칸이 깨지는 시각(PIPS-1 개)
var CRACK_DUR = 520; // ms, 한 칸이 다 깨지는 데 걸리는 시간
var STAGGER = 150; // ms, 균열 두 줄 사이 지연 — 칸 단위 stagger
var PAUSE = 500;
var CYCLE = EVENT_T[EVENT_T.length - 1] + CRACK_DUR + PAUSE;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var gb = document.getElementById('gb');
var track = document.getElementById('gbTrack');
var valueEl = document.getElementById('gbValue');
var breakBtn = document.getElementById('gbBreak');
var pips = [];
for (var i = 0; i < PIPS; i++) {
var pip = document.createElement('div');
pip.className = 'gb__pip';
var fillEl = document.createElement('div');
fillEl.className = 'gb__pipFill';
var c1 = document.createElement('div');
c1.className = 'gb__crack gb__crack--a';
var c2 = document.createElement('div');
c2.className = 'gb__crack gb__crack--b';
pip.appendChild(fillEl);
pip.appendChild(c1);
pip.appendChild(c2);
track.appendChild(pip);
pips.push({ el: pip, fill: fillEl, c1: c1, c2: c2 });
}
var isDemo = true;
var appliedIdx = 0; // 몇 칸이 깨지는 이벤트를 지났는가 (0..PIPS-1)
var eventTs = []; // 실제 깨진 시각 기록(자동·수동 공용)
var cycleStart = 0;
// 칸 pipIdx 는 오른쪽부터 먼저 깨진다: eventIndex = PIPS-1-pipIdx
function stateFor(pipIdx, ts) {
var eventIndex = PIPS - 1 - pipIdx;
if (eventIndex < 0 || eventIndex >= appliedIdx) {
return { c1: 0, c2: 0, fill: 1 };
}
var t0 = eventTs[eventIndex];
var dt = Math.max(0, ts - t0);
var c1 = Math.min(1, dt / (CRACK_DUR * 0.45));
var c2 = Math.min(1, Math.max(0, dt - STAGGER) / (CRACK_DUR * 0.45));
var fill = 1 - Math.min(1, Math.max(0, dt - STAGGER) / (CRACK_DUR - STAGGER));
return { c1: c1, c2: c2, fill: fill };
}
function render(ts) {
for (var i = 0; i < PIPS; i++) {
var s = stateFor(i, ts);
pips[i].fill.style.opacity = s.fill.toFixed(3);
pips[i].c1.style.opacity = s.c1.toFixed(3);
pips[i].c2.style.opacity = s.c2.toFixed(3);
}
var remaining = PIPS - appliedIdx;
valueEl.textContent = remaining + ' / ' + PIPS;
track.setAttribute('aria-valuenow', String(remaining));
}
function breakOne(ts) {
eventTs[appliedIdx] = ts;
appliedIdx++;
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
breakBtn.addEventListener('click', function () {
human();
var now = performance.now();
if (appliedIdx >= PIPS - 1) { appliedIdx = 0; eventTs = []; }
else { breakOne(now); }
render(now);
});
breakBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
breakBtn.click();
});
function loop(ts) {
if (!cycleStart) cycleStart = ts;
var elapsed = ts - cycleStart;
if (isDemo) {
if (elapsed >= CYCLE) {
cycleStart = ts; elapsed = 0; appliedIdx = 0; eventTs = [];
}
for (var i = 0; i < EVENT_T.length; i++) {
if (elapsed >= EVENT_T[i] && appliedIdx <= i) {
eventTs[i] = cycleStart + EVENT_T[i];
appliedIdx = i + 1;
}
}
}
render(ts);
requestAnimationFrame(loop);
}
if (!reduce) {
requestAnimationFrame(loop);
} else {
render(0);
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './GaugeBarSegmentPipCrack.module.scss';
type Box<T> = { current: T };
const PIPS = 5; // 계산 겹 상수: 칸 개수
const EVENT_T = [400, 1300, 2200, 3100]; // ms, 자동 루프 안에서 칸이 깨지는 시각(PIPS-1 개)
const CRACK_DUR = 520; // ms, 한 칸이 다 깨지는 데 걸리는 시간
const STAGGER = 150; // ms, 균열 두 줄 사이 지연 — 칸 단위 stagger
const PAUSE = 500;
const CYCLE = EVENT_T[EVENT_T.length - 1] + CRACK_DUR + PAUSE;
type PipState = { c1: number; c2: number; fill: number };
export default function GaugeBarSegmentPipCrack() {
const pipRefs = useRef(
Array.from({ length: PIPS }, () => ({ fill: null, c1: null, c2: null }))
) as unknown as Box<{ fill: HTMLDivElement | null; c1: HTMLDivElement | null; c2: HTMLDivElement | null }[]>;
const trackRef = useRef(null) as unknown as Box<HTMLDivElement>;
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const [isDemo, setIsDemo] = useState(true);
const [remaining, setRemaining] = useState(PIPS);
const appliedIdxRef = useRef(0) as unknown as Box<number>;
const eventTsRef = useRef<number[]>([]) as unknown as Box<number[]>;
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
// 칸 pipIdx 는 오른쪽부터 먼저 깨진다: eventIndex = PIPS-1-pipIdx
function stateFor(pipIdx: number, ts: number): PipState {
const eventIndex = PIPS - 1 - pipIdx;
if (eventIndex < 0 || eventIndex >= appliedIdxRef.current) {
return { c1: 0, c2: 0, fill: 1 };
}
const t0 = eventTsRef.current[eventIndex];
const dt = Math.max(0, ts - t0);
const c1 = Math.min(1, dt / (CRACK_DUR * 0.45));
const c2 = Math.min(1, Math.max(0, dt - STAGGER) / (CRACK_DUR * 0.45));
const fill = 1 - Math.min(1, Math.max(0, dt - STAGGER) / (CRACK_DUR - STAGGER));
return { c1, c2, fill };
}
function render(ts: number) {
for (let i = 0; i < PIPS; i++) {
const s = stateFor(i, ts);
const r = pipRefs.current[i];
if (r.fill) r.fill.style.opacity = s.fill.toFixed(3);
if (r.c1) r.c1.style.opacity = s.c1.toFixed(3);
if (r.c2) r.c2.style.opacity = s.c2.toFixed(3);
}
const rem = PIPS - appliedIdxRef.current;
const track = trackRef.current;
if (track) track.setAttribute('aria-valuenow', String(rem));
setRemaining(rem);
}
function breakOne(ts: number) {
eventTsRef.current[appliedIdxRef.current] = ts;
appliedIdxRef.current++;
}
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let cycleStart = 0;
let rafId = 0;
function loop(ts: number) {
if (!cycleStart) cycleStart = ts;
let elapsed = ts - cycleStart;
if (isDemoRef.current) {
if (elapsed >= CYCLE) {
cycleStart = ts; elapsed = 0; appliedIdxRef.current = 0; eventTsRef.current = [];
}
for (let i = 0; i < EVENT_T.length; i++) {
if (elapsed >= EVENT_T[i] && appliedIdxRef.current <= i) {
eventTsRef.current[i] = cycleStart + EVENT_T[i];
appliedIdxRef.current = i + 1;
}
}
}
render(ts);
rafId = requestAnimationFrame(loop);
}
if (!reduce) {
rafId = requestAnimationFrame(loop);
} else {
render(0);
}
return () => cancelAnimationFrame(rafId);
}, []);
function onBreak() {
human();
const now = performance.now();
if (appliedIdxRef.current >= PIPS - 1) { appliedIdxRef.current = 0; eventTsRef.current = []; }
else { breakOne(now); }
render(now);
}
function onKeyDown(e: { key: string; preventDefault: () => void }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
onBreak();
}
return (
<div className={`${styles.gb} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.gbHead}>
<span className={styles.gbLabel}>보스 체력</span>
<span className={styles.gbValue}>{remaining} / {PIPS}</span>
</div>
<div
className={styles.gbTrack}
role="progressbar"
aria-label="보스 체력"
aria-valuemin={0}
aria-valuemax={PIPS}
aria-valuenow={remaining}
ref={trackRef}
>
{Array.from({ length: PIPS }, (_, i) => (
<div className={styles.gbPip} key={i}>
<div
className={styles.gbPipFill}
ref={(el: HTMLDivElement | null) => { pipRefs.current[i].fill = el; }}
/>
<div
className={`${styles.gbCrack} ${styles.gbCrackA}`}
ref={(el: HTMLDivElement | null) => { pipRefs.current[i].c1 = el; }}
/>
<div
className={`${styles.gbCrack} ${styles.gbCrackB}`}
ref={(el: HTMLDivElement | null) => { pipRefs.current[i].c2 = el; }}
/>
</div>
))}
</div>
<button type="button" className={styles.gbHit} onClick={onBreak} onKeyDown={onKeyDown}>
칸 깨기
</button>
</div>
);
}03 · 区切りピップ亀裂切替 — 帯が複数のピップに分かれ、値が1ピップ下がるとそのピップに亀裂模様が残る方式 — 計測 累積面積 1.917% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function stateFor(pipIdx, ts) {
var eventIndex = PIPS - 1 - pipIdx;
if (eventIndex < 0 || eventIndex >= appliedIdx) {
return { c1: 0, c2: 0, fill: 1 };
}
var t0 = eventTs[eventIndex];
var dt = Math.max(0, ts - t0);
var c1 = Math.min(1, dt / (CRACK_DUR * 0.45));
var c2 = Math.min(1, Math.max(0, dt - STAGGER) / (CRACK_DUR * 0.45));
var fill = 1 - Math.min(1, Math.max(0, dt - STAGGER) / (CRACK_DUR - STAGGER));
return { c1: c1, c2: c2, fill: fill };
}
数値が語ること
03番は累積面積1.92%・平均強度71.8を23サンプル中14フレームで示し、stateFor() が9つ全部のピップに同じ CRACK_DUR/STAGGER の計算を毎フレーム走らせる一方で、appliedIdx の範囲に入っているピップだけが実際に変化し、残りは常に { c1: 0, c2: 0, fill: 1 } を返すだけです。
04過剰回復シールド重ね切替 — 値が上限を超えるとその超過分が半透明の層として帯の上に重なる方式
値が上限値を超えた瞬間、その差分の幅が半透明の層として既存の帯の上に重なって現れます。シールドが乗った体力表示や、上限を超えて溢れたリソースバーに向く方式で、超過分がひとつの層として見えることで「今どれだけ余っているか」が一目で伝わります。

<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">55 / 100</span>
</div>
<div class="gb__track" role="progressbar" aria-label="HP" aria-valuemin="0" aria-valuemax="100" aria-valuenow="55" id="gbTrack">
<div class="gb__fill" id="gbFill"></div>
<div class="gb__shield" id="gbShield"></div>
</div>
<button type="button" class="gb__hit" id="gbHeal">オーバーヒール追加</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html loop() 이 매 프레임 상한 위 초과분을 exp(-DECAY_RATE*dt) 로
// 스스로 줄이고, 그 값에서 쉴드 겹의 폭·투명도를 다시 계산해 그린다.
@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: 55%; border-radius: $r-pill; background: $subject-blue-deep; will-change: width; }
.gb__shield {
position: absolute; left: 0; top: 0; height: 100%; width: 0%;
border-radius: $r-pill; background: $subject-sky; opacity: 0; z-index: 1;
will-change: width, 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__shield { display: none; }
}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: 55%;
border-radius: 999px;
background: #0a4fe8;
will-change: width;
}
.gb__shield {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 0%;
border-radius: 999px;
background: #6ec8ff;
opacity: 0;
z-index: 1;
will-change: width, 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__shield {
display: none;
}
}(function () {
var HP_MAX = 100; // 상한
var OVERHEAL_CAP = 50; // 계산 겹 상수: 상한 위로 표시되는 쉴드 겹의 최대 폭 기준값
var START = 55;
var EVENTS = [[300, 45], [1300, 55], [2300, -25], [3200, -15]]; // ms, 값 변화(+회복/-피해)
var DECAY_RATE = 0.00065; // 계산 겹: 상한 위 초과분이 계속 스스로 줄어드는 비율(ms 당)
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 shield = document.getElementById('gbShield');
var valueEl = document.getElementById('gbValue');
var healBtn = document.getElementById('gbHeal');
var isDemo = true;
var value = START;
var appliedIdx = 0;
var cycleStart = 0;
var lastTs = 0;
function render() {
var base = Math.min(value, HP_MAX);
var excess = Math.max(0, value - HP_MAX);
var shieldRatio = Math.min(1, excess / OVERHEAL_CAP);
fill.style.width = base.toFixed(2) + '%';
// 계산 겹: 초과분 폭 = 상한 위 값 / 쉴드 표시 기준값 — 반투명 겹의 폭·투명도를 함께 구동
shield.style.width = (shieldRatio * 100).toFixed(2) + '%';
shield.style.opacity = shieldRatio < 0.02 ? '0' : (0.4 + shieldRatio * 0.5).toFixed(3);
track.setAttribute('aria-valuenow', String(Math.round(base)));
valueEl.textContent = Math.round(value) + ' / ' + HP_MAX;
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
healBtn.addEventListener('click', function () {
human();
value = Math.max(0, Math.min(HP_MAX + OVERHEAL_CAP, value + 30));
render();
});
healBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
healBtn.click();
});
function loop(ts) {
if (!cycleStart) { cycleStart = ts; lastTs = ts; }
var dt = ts - lastTs;
lastTs = ts;
var elapsed = ts - cycleStart;
if (isDemo) {
if (elapsed >= EVENTS[EVENTS.length - 1][0] + 700) {
cycleStart = ts; elapsed = 0; appliedIdx = 0; value = START;
}
while (appliedIdx < EVENTS.length && elapsed >= EVENTS[appliedIdx][0]) {
value = Math.max(0, Math.min(HP_MAX + OVERHEAL_CAP, value + EVENTS[appliedIdx][1]));
appliedIdx++;
}
}
if (value > HP_MAX) {
value = HP_MAX + (value - HP_MAX) * Math.exp(-DECAY_RATE * dt);
}
render();
requestAnimationFrame(loop);
}
if (!reduce) {
requestAnimationFrame(loop);
} else {
render();
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './GaugeBarOverhealShieldCap.module.scss';
type Box<T> = { current: T };
const HP_MAX = 100; // 상한
const OVERHEAL_CAP = 50; // 계산 겹 상수: 상한 위로 표시되는 쉴드 겹의 최대 폭 기준값
const START = 55;
const EVENTS: [number, number][] = [[300, 45], [1300, 55], [2300, -25], [3200, -15]]; // ms, 값 변화(+회복/-피해)
const DECAY_RATE = 0.00065; // 계산 겹: 상한 위 초과분이 계속 스스로 줄어드는 비율(ms 당)
export default function GaugeBarOverhealShieldCap() {
const fillRef = useRef(null) as unknown as Box<HTMLDivElement>;
const shieldRef = 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(START);
const valueRef = useRef(START) as unknown as Box<number>;
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
function render() {
const v = valueRef.current;
const base = Math.min(v, HP_MAX);
const excess = Math.max(0, v - HP_MAX);
const shieldRatio = Math.min(1, excess / OVERHEAL_CAP);
const fill = fillRef.current;
const shield = shieldRef.current;
const track = trackRef.current;
if (fill) fill.style.width = base.toFixed(2) + '%';
// 계산 겹: 초과분 폭 = 상한 위 값 / 쉴드 표시 기준값 — 반투명 겹의 폭·투명도를 함께 구동
if (shield) {
shield.style.width = (shieldRatio * 100).toFixed(2) + '%';
shield.style.opacity = shieldRatio < 0.02 ? '0' : (0.4 + shieldRatio * 0.5).toFixed(3);
}
if (track) track.setAttribute('aria-valuenow', String(Math.round(base)));
setValue(Math.round(v));
}
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 >= EVENTS[EVENTS.length - 1][0] + 700) {
cycleStart = ts; elapsed = 0; appliedIdx = 0; valueRef.current = START;
}
while (appliedIdx < EVENTS.length && elapsed >= EVENTS[appliedIdx][0]) {
valueRef.current = Math.max(0, Math.min(HP_MAX + OVERHEAL_CAP, valueRef.current + EVENTS[appliedIdx][1]));
appliedIdx++;
}
}
if (valueRef.current > HP_MAX) {
valueRef.current = HP_MAX + (valueRef.current - HP_MAX) * Math.exp(-DECAY_RATE * dt);
}
render();
rafId = requestAnimationFrame(loop);
}
if (!reduce) {
rafId = requestAnimationFrame(loop);
} else {
render();
}
return () => cancelAnimationFrame(rafId);
}, []);
function onHeal() {
human();
valueRef.current = Math.max(0, Math.min(HP_MAX + OVERHEAL_CAP, valueRef.current + 30));
render();
}
function onKeyDown(e: { key: string; preventDefault: () => void }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
onHeal();
}
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={Math.min(value, HP_MAX)}
ref={trackRef}
>
<div className={styles.gbFill} ref={fillRef} />
<div className={styles.gbShield} ref={shieldRef} />
</div>
<button type="button" className={styles.gbHit} onClick={onHeal} onKeyDown={onKeyDown}>
회복 얹기
</button>
</div>
);
}04 · 過剰回復シールド重ね切替 — 値が上限を超えるとその超過分が半透明の層として帯の上に重なる方式 — 計測 累積面積 2.868% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function render() {
var base = Math.min(value, HP_MAX);
var excess = Math.max(0, value - HP_MAX);
var shieldRatio = Math.min(1, excess / OVERHEAL_CAP);
fill.style.width = base.toFixed(2) + '%';
// 計算層: 超過幅 = 上限を超えた値 ÷ シールド表示基準値 — 半透明レイヤーの幅と不透明度を同時に駆動する
shield.style.width = (shieldRatio * 100).toFixed(2) + '%';
shield.style.opacity = shieldRatio < 0.02 ? '0' : (0.4 + shieldRatio * 0.5).toFixed(3);
track.setAttribute('aria-valuenow', String(Math.round(base)));
valueEl.textContent = Math.round(value) + ' / ' + HP_MAX;
}
数値が語ること
04番は累積面積2.87%・平均強度99.7——9つの中で最も高い強度——を23サンプル中13フレームで示し、OVERHEAL_CAP が上限の上にどこまで表示を伸ばせるかを決め、DECAY_RATE がその超過分を毎フレーム自分で上限まで戻すため、次の被弾がなくてもシールドは自然に薄くなっていきます。
05危険域拍動切替 — 値が定めた低い区間に入ると帯全体が一定のリズムで明滅する方式
値が定めた低い境界を下回ると、帯の明るさの値が定めたリズムで上下し始めます。体力が低いときの警告表示や、残り時間が少ないときの通知バーに向く方式で、境界を下回った瞬間から拍動が始まることで危険そのものをリズムとして伝えます。

<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">70 / 100</span>
</div>
<div class="gb__track" role="progressbar" aria-label="HP" aria-valuemin="0" aria-valuemax="100" aria-valuenow="70" id="gbTrack">
<div class="gb__fill" id="gbFill"></div>
</div>
<button type="button" class="gb__hit" id="gbHit">ダメージを受ける</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html pulseFor() 가 잔량 v 에서 매 프레임 박동 주기·진폭을 반비례로 다시 계산한다
// (잔량이 낮을수록 주기는 짧고 진폭은 크다) — 고정 keyframe 반복이 아니다.
@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__fill {
position: absolute; left: 0; top: 0; height: 100%; width: 70%; border-radius: $r-pill;
background: $error; will-change: filter, box-shadow;
}
.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 { filter: none !important; box-shadow: none !important; }
}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__fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 70%;
border-radius: 999px;
background: #c2361a;
will-change: filter, box-shadow;
}
.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 {
filter: none !important;
box-shadow: none !important;
}
}(function () {
var HP_MAX = 100;
var THRESHOLD = 30; // 계산 겹 상수: 위험 구간 진입 기준
var START = 70;
var EVENTS = [[400, -20], [1400, -15], [2200, -10], [3000, -15]]; // ms, 값 변화
var PERIOD_MAX = 900; // ms, 임계 진입 직후(잔량 많음) 박동 주기
var PERIOD_MIN = 280; // ms, 잔량 0 에 가까울 때 박동 주기
var AMP_MIN = 0.12; // 임계 진입 직후 박동 진폭
var AMP_MAX = 0.5; // 잔량 0 에 가까울 때 박동 진폭
var HIT_LIST = [20, 15, 10, 15, 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 valueEl = document.getElementById('gbValue');
var hitBtn = document.getElementById('gbHit');
var isDemo = true;
var value = START;
var appliedIdx = 0;
var cycleStart = 0;
var hitIdx = 0;
function pulseFor(v, ts) {
if (v >= THRESHOLD) return { brightness: 1, glow: 0 };
// 계산 겹: 잔량이 낮을수록 박동 주기는 짧아지고 진폭은 커진다(반비례) — 매 프레임 v 에서 다시 계산
var ratio = (THRESHOLD - v) / THRESHOLD; // 0..1
var period = PERIOD_MAX - (PERIOD_MAX - PERIOD_MIN) * ratio;
var amp = AMP_MIN + (AMP_MAX - AMP_MIN) * ratio;
var phase = (ts % period) / period * Math.PI * 2;
var osc = Math.sin(phase);
return { brightness: 1 + amp * osc, glow: Math.max(0, osc) * amp };
}
function render(ts) {
var v = Math.max(0, Math.min(HP_MAX, value));
fill.style.width = v.toFixed(2) + '%';
var p = pulseFor(v, ts);
fill.style.filter = 'brightness(' + p.brightness.toFixed(3) + ')';
fill.style.boxShadow = p.glow > 0.02
? '0 0 ' + (6 + p.glow * 18).toFixed(1) + 'px ' + (p.glow * 6).toFixed(1) + 'px rgba(255,120,90,' + p.glow.toFixed(3) + ')'
: 'none';
track.setAttribute('aria-valuenow', String(Math.round(v)));
valueEl.textContent = Math.round(v) + ' / ' + HP_MAX;
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
hitBtn.addEventListener('click', function () {
human();
if (value <= 0) { value = START; }
else { value = Math.max(0, value - HIT_LIST[hitIdx % HIT_LIST.length]); hitIdx++; }
render(performance.now());
});
hitBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
hitBtn.click();
});
function loop(ts) {
if (!cycleStart) cycleStart = ts;
var elapsed = ts - cycleStart;
if (isDemo) {
if (elapsed >= EVENTS[EVENTS.length - 1][0] + 1000) {
cycleStart = ts; elapsed = 0; appliedIdx = 0; value = START;
}
while (appliedIdx < EVENTS.length && elapsed >= EVENTS[appliedIdx][0]) {
value = Math.max(0, Math.min(HP_MAX, value + EVENTS[appliedIdx][1]));
appliedIdx++;
}
}
render(ts);
requestAnimationFrame(loop);
}
if (!reduce) {
requestAnimationFrame(loop);
} else {
render(0);
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './GaugeBarCriticalPulse.module.scss';
type Box<T> = { current: T };
const HP_MAX = 100;
const THRESHOLD = 30; // 계산 겹 상수: 위험 구간 진입 기준
const START = 70;
const EVENTS: [number, number][] = [[400, -20], [1400, -15], [2200, -10], [3000, -15]]; // ms, 값 변화
const PERIOD_MAX = 900; // ms, 임계 진입 직후(잔량 많음) 박동 주기
const PERIOD_MIN = 280; // ms, 잔량 0 에 가까울 때 박동 주기
const AMP_MIN = 0.12; // 임계 진입 직후 박동 진폭
const AMP_MAX = 0.5; // 잔량 0 에 가까울 때 박동 진폭
const HIT_LIST = [20, 15, 10, 15, 20];
function pulseFor(v: number, ts: number) {
if (v >= THRESHOLD) return { brightness: 1, glow: 0 };
// 계산 겹: 잔량이 낮을수록 박동 주기는 짧아지고 진폭은 커진다(반비례) — 매 프레임 v 에서 다시 계산
const ratio = (THRESHOLD - v) / THRESHOLD; // 0..1
const period = PERIOD_MAX - (PERIOD_MAX - PERIOD_MIN) * ratio;
const amp = AMP_MIN + (AMP_MAX - AMP_MIN) * ratio;
const phase = ((ts % period) / period) * Math.PI * 2;
const osc = Math.sin(phase);
return { brightness: 1 + amp * osc, glow: Math.max(0, osc) * amp };
}
export default function GaugeBarCriticalPulse() {
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 [value, setValue] = useState(START);
const valueRef = useRef(START) 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 v = Math.max(0, Math.min(HP_MAX, valueRef.current));
const fill = fillRef.current;
const track = trackRef.current;
if (fill) {
fill.style.width = v.toFixed(2) + '%';
const p = pulseFor(v, ts);
fill.style.filter = 'brightness(' + p.brightness.toFixed(3) + ')';
fill.style.boxShadow = p.glow > 0.02
? '0 0 ' + (6 + p.glow * 18).toFixed(1) + 'px ' + (p.glow * 6).toFixed(1) + 'px rgba(255,120,90,' + p.glow.toFixed(3) + ')'
: 'none';
}
if (track) track.setAttribute('aria-valuenow', String(Math.round(v)));
setValue(Math.round(v));
}
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;
let elapsed = ts - cycleStart;
if (isDemoRef.current) {
if (elapsed >= EVENTS[EVENTS.length - 1][0] + 1000) {
cycleStart = ts; elapsed = 0; appliedIdx = 0; valueRef.current = START;
}
while (appliedIdx < EVENTS.length && elapsed >= EVENTS[appliedIdx][0]) {
valueRef.current = Math.max(0, Math.min(HP_MAX, valueRef.current + EVENTS[appliedIdx][1]));
appliedIdx++;
}
}
render(ts);
rafId = requestAnimationFrame(loop);
}
if (!reduce) {
rafId = requestAnimationFrame(loop);
} else {
render(0);
}
return () => cancelAnimationFrame(rafId);
}, []);
function onHit() {
human();
if (valueRef.current <= 0) { valueRef.current = START; }
else { valueRef.current = Math.max(0, valueRef.current - HIT_LIST[hitIdxRef.current % HIT_LIST.length]); hitIdxRef.current++; }
render(performance.now());
}
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.gbFill} ref={fillRef} />
</div>
<button type="button" className={styles.gbHit} onClick={onHit} onKeyDown={onKeyDown}>
피해 입기
</button>
</div>
);
}05 · 危険域拍動切替 — 値が定めた低い区間に入ると帯全体が一定のリズムで明滅する方式 — 計測 累積面積 1.985% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function pulseFor(v, ts) {
if (v >= THRESHOLD) return { brightness: 1, glow: 0 };
// 計算層: 残量が低いほど拍動周期は短く振幅は大きくなる(反比例) — 毎フレーム v から再計算
var ratio = (THRESHOLD - v) / THRESHOLD; // 0..1
var period = PERIOD_MAX - (PERIOD_MAX - PERIOD_MIN) * ratio;
var amp = AMP_MIN + (AMP_MAX - AMP_MIN) * ratio;
var phase = (ts % period) / period * Math.PI * 2;
var osc = Math.sin(phase);
return { brightness: 1 + amp * osc, glow: Math.max(0, osc) * amp };
}
数値が語ること
05番は累積面積1.99%・平均強度75.4を23サンプル中7フレーム——9つの中で最も動いたフレームが少ない項目——で示し、pulseFor() は値が THRESHOLD を下回るまで { brightness: 1, glow: 0 } を返すだけで何もせず、残量がゼロに近づいてはじめて PERIOD_MIN の短い周期が主導権を握ります。
06チャージ保持放出切替 — 押している間に値がゆっくり満ち、離した瞬間に一度に放出される方式
押している間に値が定めた速さで満ち、離した瞬間にその値が0まで一気に落ち、別の放出表示が点灯します。強攻撃のチャージ表示や、溜めてから放つリソースバーに向く方式で、放出の瞬間に別枠の表示が跳ねることで「今出た」という手触りを帯そのものの動きとは別に加えます。

<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 class="gb__burst" id="gbBurst"></div>
</div>
<button type="button" class="gb__hit" id="gbCharge">押し続ける</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html burstAt() 이 감쇠 코사인 진동(스프링 방정식)으로 방출 순간의
// 링 크기·투명도를 매 프레임 계산한다 — 손을 떼는 순간 값은 즉시 0, 링만 스프링으로 남는다.
@include stage($stage-yellow);
.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: visible; }
.gb__fill { position: absolute; left: 0; top: 0; height: 100%; width: 0%; border-radius: $r-pill; background: $subject-lilac; will-change: width; }
.gb__burst {
position: absolute; top: 50%; left: 0%;
width: 16px; height: 16px; border-radius: 50%;
background: $subject-lilac; opacity: 0;
transform: translate(-50%, -50%) scale(0);
will-change: 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; touch-action: none;
}
.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__burst { display: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
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: visible;
}
.gb__fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 0%;
border-radius: 999px;
background: #b9a0ff;
will-change: width;
}
.gb__burst {
position: absolute;
top: 50%;
left: 0%;
width: 16px;
height: 16px;
border-radius: 50%;
background: #b9a0ff;
opacity: 0;
transform: translate(-50%, -50%) scale(0);
will-change: 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;
touch-action: none;
}
.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__burst {
display: none;
}
}(function () {
var CHARGE_MAX = 100;
var CHARGE_RATE = 0.062; // 계산 겹 상수: ms 당 チャージ量
var HOLD_DUR = 1550; // ms, 자동 루프가 누르고 있는 시간
var RELEASE_DUR = 700; // ms, 방출 스프링 지속
var RELEASE_FREQ = 2 * Math.PI * 2.1 / RELEASE_DUR; // 계산 겹: 스프링 각속도
var RELEASE_ZETA = 3.6; // 계산 겹: 스프링 감쇠 상수
var PAUSE = 700;
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 burst = document.getElementById('gbBurst');
var valueEl = document.getElementById('gbValue');
var chargeBtn = document.getElementById('gbCharge');
var isDemo = true;
var value = 0;
var holding = false;
var holdStart = 0;
var releaseStart = -9999;
var releaseFromPct = 0;
var cycleStart = 0;
var demoPhase = 'charging'; // charging → released → pause
function burstAt(t) {
// 계산 겹: 방출 스프링 — 감쇠 코사인 진동으로 목표를 넘어섰다 되돌아오는 위치를 계산
if (t < 0 || t > RELEASE_DUR) return { scale: 0, alpha: 0 };
var x = t / RELEASE_DUR;
var amp = Math.exp(-RELEASE_ZETA * x);
var pos = 1 - amp * Math.cos(RELEASE_FREQ * t);
return { scale: 1 + pos * 1.1, alpha: Math.max(0, amp * 1.15) };
}
function render(ts) {
fill.style.width = value.toFixed(2) + '%';
valueEl.textContent = Math.round(value) + '%';
track.setAttribute('aria-valuenow', String(Math.round(value)));
var b = burstAt(ts - releaseStart);
burst.style.left = releaseFromPct.toFixed(2) + '%';
burst.style.opacity = b.alpha < 0.02 ? '0' : b.alpha.toFixed(3);
burst.style.transform = 'translate(-50%, -50%) scale(' + b.scale.toFixed(3) + ')';
}
function startCharge(ts) {
holding = true;
holdStart = ts;
}
function releaseCharge(ts) {
holding = false;
releaseFromPct = value;
releaseStart = ts;
value = 0;
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
chargeBtn.addEventListener('pointerdown', function (ev) {
human();
startCharge(performance.now());
});
chargeBtn.addEventListener('pointerup', function () { if (holding) releaseCharge(performance.now()); });
chargeBtn.addEventListener('pointerleave', function () { if (holding) releaseCharge(performance.now()); });
chargeBtn.addEventListener('pointercancel', function () { if (holding) releaseCharge(performance.now()); });
chargeBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
if (!holding) { human(); startCharge(performance.now()); }
});
chargeBtn.addEventListener('keyup', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
if (holding) releaseCharge(performance.now());
});
function loop(ts) {
if (!cycleStart) cycleStart = ts;
if (isDemo) {
var elapsed = ts - cycleStart;
if (demoPhase === 'charging' && elapsed >= HOLD_DUR) {
releaseFromPct = value;
releaseStart = ts;
value = 0;
demoPhase = 'released';
} else if (demoPhase === 'released' && ts - releaseStart >= RELEASE_DUR + PAUSE) {
cycleStart = ts;
demoPhase = 'charging';
value = 0;
} else if (demoPhase === 'charging') {
value = Math.min(CHARGE_MAX, elapsed * CHARGE_RATE);
}
} else if (holding) {
value = Math.min(CHARGE_MAX, (ts - holdStart) * CHARGE_RATE);
}
render(ts);
requestAnimationFrame(loop);
}
if (!reduce) {
requestAnimationFrame(loop);
} else {
render(0);
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './GaugeBarChargeHoldRelease.module.scss';
type Box<T> = { current: T };
const CHARGE_MAX = 100;
const CHARGE_RATE = 0.062; // 계산 겹 상수: ms 당 충전량
const HOLD_DUR = 1550; // ms, 자동 루프가 누르고 있는 시간
const RELEASE_DUR = 700; // ms, 방출 스프링 지속
const RELEASE_FREQ = (2 * Math.PI * 2.1) / RELEASE_DUR; // 계산 겹: 스프링 각속도
const RELEASE_ZETA = 3.6; // 계산 겹: 스프링 감쇠 상수
const PAUSE = 700;
function burstAt(t: number) {
// 계산 겹: 방출 스프링 — 감쇠 코사인 진동으로 목표를 넘어섰다 되돌아오는 위치를 계산
if (t < 0 || t > RELEASE_DUR) return { scale: 0, alpha: 0 };
const x = t / RELEASE_DUR;
const amp = Math.exp(-RELEASE_ZETA * x);
const pos = 1 - amp * Math.cos(RELEASE_FREQ * t);
return { scale: 1 + pos * 1.1, alpha: Math.max(0, amp * 1.15) };
}
export default function GaugeBarChargeHoldRelease() {
const fillRef = useRef(null) as unknown as Box<HTMLDivElement>;
const burstRef = 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(0);
const valueRef = useRef(0) as unknown as Box<number>;
const holdingRef = useRef(false) as unknown as Box<boolean>;
const holdStartRef = useRef(0) as unknown as Box<number>;
const releaseStartRef = useRef(-9999) as unknown as Box<number>;
const releaseFromPctRef = useRef(0) as unknown as Box<number>;
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
function render(ts: number) {
const fill = fillRef.current;
const burst = burstRef.current;
const track = trackRef.current;
if (fill) fill.style.width = valueRef.current.toFixed(2) + '%';
if (track) track.setAttribute('aria-valuenow', String(Math.round(valueRef.current)));
setValue(Math.round(valueRef.current));
const b = burstAt(ts - releaseStartRef.current);
if (burst) {
burst.style.left = releaseFromPctRef.current.toFixed(2) + '%';
burst.style.opacity = b.alpha < 0.02 ? '0' : b.alpha.toFixed(3);
burst.style.transform = 'translate(-50%, -50%) scale(' + b.scale.toFixed(3) + ')';
}
}
function startCharge(ts: number) {
holdingRef.current = true;
holdStartRef.current = ts;
}
function releaseCharge(ts: number) {
holdingRef.current = false;
releaseFromPctRef.current = valueRef.current;
releaseStartRef.current = ts;
valueRef.current = 0;
}
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let cycleStart = 0;
let demoPhase: 'charging' | 'released' = 'charging';
let rafId = 0;
function loop(ts: number) {
if (!cycleStart) cycleStart = ts;
if (isDemoRef.current) {
const elapsed = ts - cycleStart;
if (demoPhase === 'charging' && elapsed >= HOLD_DUR) {
releaseFromPctRef.current = valueRef.current;
releaseStartRef.current = ts;
valueRef.current = 0;
demoPhase = 'released';
} else if (demoPhase === 'released' && ts - releaseStartRef.current >= RELEASE_DUR + PAUSE) {
cycleStart = ts;
demoPhase = 'charging';
valueRef.current = 0;
} else if (demoPhase === 'charging') {
valueRef.current = Math.min(CHARGE_MAX, elapsed * CHARGE_RATE);
}
} else if (holdingRef.current) {
valueRef.current = Math.min(CHARGE_MAX, (ts - holdStartRef.current) * CHARGE_RATE);
}
render(ts);
rafId = requestAnimationFrame(loop);
}
if (!reduce) {
rafId = requestAnimationFrame(loop);
} else {
render(0);
}
return () => cancelAnimationFrame(rafId);
}, []);
function onPointerDown() {
human();
startCharge(performance.now());
}
function onPointerUp() {
if (holdingRef.current) releaseCharge(performance.now());
}
function onKeyDown(e: { key: string; preventDefault: () => void }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
if (!holdingRef.current) { human(); startCharge(performance.now()); }
}
function onKeyUp(e: { key: string }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
if (holdingRef.current) releaseCharge(performance.now());
}
return (
<div className={`${styles.gb} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.gbHead}>
<span className={styles.gbLabel}>강공격</span>
<span className={styles.gbValue}>{value}%</span>
</div>
<div
className={styles.gbTrack}
role="progressbar"
aria-label="충전량"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={value}
ref={trackRef}
>
<div className={styles.gbFill} ref={fillRef} />
<div className={styles.gbBurst} ref={burstRef} />
</div>
<button
type="button"
className={styles.gbHit}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
누르고 있기
</button>
</div>
);
}06 · チャージ保持放出切替 — 押している間に値がゆっくり満ち、離した瞬間に一度に放出される方式 — 計測 累積面積 3.328% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function burstAt(t) {
// 計算層: 放出スプリング — 減衰コサイン振動で目標を通り過ぎてから戻る位置を計算する
if (t < 0 || t > RELEASE_DUR) return { scale: 0, alpha: 0 };
var x = t / RELEASE_DUR;
var amp = Math.exp(-RELEASE_ZETA * x);
var pos = 1 - amp * Math.cos(RELEASE_FREQ * t);
return { scale: 1 + pos * 1.1, alpha: Math.max(0, amp * 1.15) };
}
function render(ts) {
fill.style.width = value.toFixed(2) + '%';
valueEl.textContent = Math.round(value) + '%';
track.setAttribute('aria-valuenow', String(Math.round(value)));
var b = burstAt(ts - releaseStart);
burst.style.left = releaseFromPct.toFixed(2) + '%';
burst.style.opacity = b.alpha < 0.02 ? '0' : b.alpha.toFixed(3);
burst.style.transform = 'translate(-50%, -50%) scale(' + b.scale.toFixed(3) + ')';
}
数値が語ること
06番は累積面積3.33%・平均強度76.6を23サンプル中14フレームで示し、CHARGE_RATE が押している1ミリ秒あたり何%満ちるかを決め、離した瞬間からは burstAt() が RELEASE_ZETA と RELEASE_FREQ で減衰コサインの行き過ぎを計算するという、CSSの @keyframes からではない動きになっています。
07複数レーン比較切替 — 並んだ2本以上の帯がそれぞれ異なる値で並行して満ちる方式
並んだ各帯の塗り幅の値がそれぞれ別の速さで上がり、帯同士の差が広がったり狭まったりします。複数対象の体力を一画面で比較する表示や、順位・進行度の比較に向く方式で、常に動き続けるサイン波の上にクリックの一撃だけがオフセットとして加わります。

<main class="stage">
<div class="gb is-demo" id="gb">
<div class="gb__head">
<span class="gb__label">進行度比較</span>
</div>
<div class="gb__lanes" id="gbLanes"></div>
<button type="button" class="gb__hit" id="gbJolt">順位を揺らす</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html render() 가 매 프레임 4레인 값을 계산해 내림차순 정렬하고
// 선두·2위 차이만큼만 밝은 겹을 얹는다. 왕관 표시는 DOM 순서를 바꾸지 않고
// translateY 로 현재 선두 행에만 옮겨진다 — 값의 사인 궤적도 계산이다.
@include stage($stage-orange);
.gb { width: 100%; max-width: 280px; background: $subject-cream; border-radius: $r-card; padding: $sp-5; box-shadow: $shadow-raised; box-sizing: border-box; }
.gb__head { margin-bottom: $sp-3; }
.gb__label { font-size: 12px; font-weight: 700; letter-spacing: .04em; color: rgba($subject-ink, .72); text-transform: uppercase; }
.gb__lanes { position: relative; display: flex; flex-direction: column; gap: $sp-2; }
.gb__row { display: flex; align-items: center; gap: $sp-2; height: 22px; }
.gb__laneLabel { width: 14px; font-size: 12px; font-weight: 700; color: $subject-ink; flex: 0 0 auto; }
.gb__laneTrack { position: relative; flex: 1 1 auto; height: 10px; border-radius: $r-pill; background: rgba($subject-ink, .1); overflow: hidden; }
.gb__laneFill { position: absolute; left: 0; top: 0; height: 100%; width: 0%; border-radius: $r-pill; background: $subject-blue; will-change: width; }
.gb__laneFill--lead { background: $subject-blue-deep; }
.gb__laneLead {
position: absolute; top: 0; height: 100%; width: 0%; left: 0%;
background: $subject-mint; opacity: 0; will-change: width, left, opacity;
}
.gb__crown {
position: absolute; left: -2px; top: 0;
width: 0; height: 0; border-style: solid;
border-width: 5px 0 5px 7px;
border-color: transparent transparent transparent $subject-orange-deep;
transform: translateY(0px); will-change: 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-3; }
.gb__head { margin-bottom: $sp-2; }
.gb__label { font-size: 10px; }
.gb__lanes { gap: $sp-1; }
.gb__row { height: 14px; gap: $sp-1; }
.gb__laneLabel { font-size: 10px; }
.gb__crown { border-width: 4px 0 4px 6px; }
.gb__hit { margin-top: $sp-2; padding: $sp-2 0; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.gb__laneFill, .gb__laneLead, .gb__crown { transition: none !important; }
}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);
}
.gb {
width: 100%;
max-width: 280px;
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.gb__head {
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__lanes {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
}
.gb__row {
display: flex;
align-items: center;
gap: 8px;
height: 22px;
}
.gb__laneLabel {
width: 14px;
font-size: 12px;
font-weight: 700;
color: #17141a;
flex: 0 0 auto;
}
.gb__laneTrack {
position: relative;
flex: 1 1 auto;
height: 10px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
overflow: hidden;
}
.gb__laneFill {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 0%;
border-radius: 999px;
background: #2f6df6;
will-change: width;
}
.gb__laneFill--lead {
background: #0a4fe8;
}
.gb__laneLead {
position: absolute;
top: 0;
height: 100%;
width: 0%;
left: 0%;
background: #4cd4a6;
opacity: 0;
will-change: width, left, opacity;
}
.gb__crown {
position: absolute;
left: -2px;
top: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 5px 0 5px 7px;
border-color: transparent transparent transparent #c93000;
transform: translateY(0px);
will-change: 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: 12px;
}
.gb__head {
margin-bottom: 8px;
}
.gb__label {
font-size: 10px;
}
.gb__lanes {
gap: 4px;
}
.gb__row {
height: 14px;
gap: 4px;
}
.gb__laneLabel {
font-size: 10px;
}
.gb__crown {
border-width: 4px 0 4px 6px;
}
.gb__hit {
margin-top: 8px;
padding: 8px 0;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.gb__laneFill, .gb__laneLead, .gb__crown {
transition: none !important;
}
}(function () {
// 계산 겹 상수: 레인마다 다른 위상·주파수의 사인 궤적 — 시드 고정, Math.random 미사용
var LANES = [
{ label: 'A', base: 55, amp: 22, freq: .55, phase: 0.0, offset: 0 },
{ label: 'B', base: 60, amp: 18, freq: .70, phase: 1.3, offset: 0 },
{ label: 'C', base: 45, amp: 25, freq: .40, phase: 2.8, offset: 0 },
{ label: 'D', base: 50, amp: 20, freq: .62, phase: 4.1, offset: 0 }
];
var JOLTS = [
[0, 0, 20, 0],
[-15, 0, 0, 18],
[0, 22, -10, 0],
[16, -12, 0, 0]
]; // 클릭마다 순환 적용되는 레인별 고정 오프셋 증가량
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var gb = document.getElementById('gb');
var lanesEl = document.getElementById('gbLanes');
var joltBtn = document.getElementById('gbJolt');
var rows = LANES.map(function (l) {
var row = document.createElement('div');
row.className = 'gb__row';
var label = document.createElement('span');
label.className = 'gb__laneLabel';
label.textContent = l.label;
var track = document.createElement('div');
track.className = 'gb__laneTrack';
var fill = document.createElement('div');
fill.className = 'gb__laneFill';
var lead = document.createElement('div');
lead.className = 'gb__laneLead';
track.appendChild(fill);
track.appendChild(lead);
row.appendChild(label);
row.appendChild(track);
lanesEl.appendChild(row);
return { row: row, fill: fill, lead: lead };
});
var crown = document.createElement('div');
crown.className = 'gb__crown';
lanesEl.appendChild(crown);
// 계산 겹: 왕관을 옮기는 간격은 고정 px 이 아니라 실제 행 간격을 측정한 값이다 — 320px 폭에서
// 행 높이·행간이 좁아져도(미디어 쿼리) 왕관이 항상 정확히 다음 행 위에 앉는다.
var rowStep = rows.length > 1
? rows[1].row.getBoundingClientRect().top - rows[0].row.getBoundingClientRect().top
: rows[0].row.getBoundingClientRect().height;
var isDemo = true;
var joltIdx = 0;
function valuesAt(t) {
return LANES.map(function (l) {
var v = l.base + l.offset + l.amp * Math.sin(l.freq * t / 1000 + l.phase);
return Math.max(4, Math.min(100, v));
});
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
joltBtn.addEventListener('click', function () {
human();
var d = JOLTS[joltIdx % JOLTS.length];
for (var i = 0; i < LANES.length; i++) LANES[i].offset += d[i];
joltIdx++;
});
joltBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
joltBtn.click();
});
function render(t) {
var values = valuesAt(t);
var sorted = values.slice().sort(function (a, b) { return b - a; });
var leaderVal = sorted[0];
var secondVal = sorted[1];
var leaderIdx = values.indexOf(leaderVal);
for (var i = 0; i < rows.length; i++) {
var v = values[i];
rows[i].fill.style.width = v.toFixed(2) + '%';
var isLeader = i === leaderIdx;
rows[i].fill.classList.toggle('gb__laneFill--lead', isLeader);
// 계산 겹: 선두와 2위의 값 차이만큼만 밝은 겹으로 얹어 "얼마나 앞섰는지" 를 보인다
if (isLeader && leaderVal - secondVal > 0.3) {
rows[i].lead.style.left = secondVal.toFixed(2) + '%';
rows[i].lead.style.width = (leaderVal - secondVal).toFixed(2) + '%';
rows[i].lead.style.opacity = '1';
} else {
rows[i].lead.style.opacity = '0';
}
}
// 계산 겹: translateY 로 왕관 표시를 현재 선두 레인 행으로 옮긴다(DOM 재배치 없이 순위만 계산)
crown.style.transform = 'translateY(' + (leaderIdx * rowStep) + 'px)';
}
function loop(ts) {
render(ts);
requestAnimationFrame(loop);
}
if (!reduce) {
requestAnimationFrame(loop);
} else {
render(0);
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './GaugeBarMultiLaneCompare.module.scss';
type Box<T> = { current: T };
// 계산 겹 상수: 레인마다 다른 위상·주파수의 사인 궤적 — 시드 고정, Math.random 미사용
type Lane = { label: string; base: number; amp: number; freq: number; phase: number; offset: number };
const LANES_INIT: Lane[] = [
{ label: 'A', base: 55, amp: 22, freq: 0.55, phase: 0.0, offset: 0 },
{ label: 'B', base: 60, amp: 18, freq: 0.70, phase: 1.3, offset: 0 },
{ label: 'C', base: 45, amp: 25, freq: 0.40, phase: 2.8, offset: 0 },
{ label: 'D', base: 50, amp: 20, freq: 0.62, phase: 4.1, offset: 0 },
];
const JOLTS = [
[0, 0, 20, 0],
[-15, 0, 0, 18],
[0, 22, -10, 0],
[16, -12, 0, 0],
]; // 클릭마다 순환 적용되는 레인별 고정 오프셋 증가량
function valuesAt(lanes: Lane[], t: number) {
return lanes.map((l) => {
const v = l.base + l.offset + l.amp * Math.sin((l.freq * t) / 1000 + l.phase);
return Math.max(4, Math.min(100, v));
});
}
export default function GaugeBarMultiLaneCompare() {
const fillRefs = useRef(Array.from({ length: 4 }, () => null)) as unknown as Box<(HTMLDivElement | null)[]>;
const leadRefs = useRef(Array.from({ length: 4 }, () => null)) as unknown as Box<(HTMLDivElement | null)[]>;
const rowRefs = useRef(Array.from({ length: 4 }, () => null)) as unknown as Box<(HTMLDivElement | null)[]>;
const crownRef = useRef(null) as unknown as Box<HTMLDivElement>;
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const [isDemo, setIsDemo] = useState(true);
const lanesRef = useRef(LANES_INIT.map((l) => ({ ...l }))) as unknown as Box<Lane[]>;
const joltIdxRef = useRef(0) as unknown as Box<number>;
// 계산 겹: 왕관 이동 간격은 고정 px 이 아니라 실제 행 간격을 측정한 값이다(vanilla 와 동일 원칙)
const rowStepRef = useRef(30) as unknown as Box<number>;
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
function render(t: number) {
const lanes = lanesRef.current;
const values = valuesAt(lanes, t);
const sorted = values.slice().sort((a, b) => b - a);
const leaderVal = sorted[0];
const secondVal = sorted[1];
const leaderIdx = values.indexOf(leaderVal);
for (let i = 0; i < lanes.length; i++) {
const v = values[i];
const fill = fillRefs.current[i];
const lead = leadRefs.current[i];
const isLeader = i === leaderIdx;
if (fill) {
fill.style.width = v.toFixed(2) + '%';
fill.classList.toggle(styles.gbLaneFillLead, isLeader);
}
// 계산 겹: 선두와 2위의 값 차이만큼만 밝은 겹으로 얹어 "얼마나 앞섰는지" 를 보인다
if (lead) {
if (isLeader && leaderVal - secondVal > 0.3) {
lead.style.left = secondVal.toFixed(2) + '%';
lead.style.width = (leaderVal - secondVal).toFixed(2) + '%';
lead.style.opacity = '1';
} else {
lead.style.opacity = '0';
}
}
}
// 계산 겹: translateY 로 왕관 표시를 현재 선두 레인 행으로 옮긴다(DOM 재배치 없이 순위만 계산)
const crown = crownRef.current;
if (crown) crown.style.transform = 'translateY(' + leaderIdx * rowStepRef.current + 'px)';
}
useEffect(() => {
const rows = rowRefs.current;
if (rows[0] && rows[1]) {
rowStepRef.current = rows[1].getBoundingClientRect().top - rows[0].getBoundingClientRect().top;
} else if (rows[0]) {
rowStepRef.current = rows[0].getBoundingClientRect().height;
}
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let rafId = 0;
function loop(ts: number) {
render(ts);
rafId = requestAnimationFrame(loop);
}
if (!reduce) {
rafId = requestAnimationFrame(loop);
} else {
render(0);
}
return () => cancelAnimationFrame(rafId);
}, []);
function onJolt() {
human();
const d = JOLTS[joltIdxRef.current % JOLTS.length];
const lanes = lanesRef.current;
for (let i = 0; i < lanes.length; i++) lanes[i].offset += d[i];
joltIdxRef.current++;
}
function onKeyDown(e: { key: string; preventDefault: () => void }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
onJolt();
}
return (
<div className={`${styles.gb} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.gbHead}>
<span className={styles.gbLabel}>진행도 비교</span>
</div>
<div className={styles.gbLanes}>
{LANES_INIT.map((l, i) => (
<div
className={styles.gbRow}
key={l.label}
ref={(el: HTMLDivElement | null) => { rowRefs.current[i] = el; }}
>
<span className={styles.gbLaneLabel}>{l.label}</span>
<div className={styles.gbLaneTrack}>
<div className={styles.gbLaneFill} ref={(el: HTMLDivElement | null) => { fillRefs.current[i] = el; }} />
<div className={styles.gbLaneLead} ref={(el: HTMLDivElement | null) => { leadRefs.current[i] = el; }} />
</div>
</div>
))}
<div className={styles.gbCrown} ref={crownRef} />
</div>
<button type="button" className={styles.gbHit} onClick={onJolt} onKeyDown={onKeyDown}>
순위 흔들기
</button>
</div>
);
}07 · 複数レーン比較切替 — 並んだ2本以上の帯がそれぞれ異なる値で並行して満ちる方式 — 計測 累積面積 2.979% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function render(t) {
var values = valuesAt(t);
var sorted = values.slice().sort(function (a, b) { return b - a; });
var leaderVal = sorted[0];
var secondVal = sorted[1];
var leaderIdx = values.indexOf(leaderVal);
for (var i = 0; i < rows.length; i++) {
var v = values[i];
rows[i].fill.style.width = v.toFixed(2) + '%';
var isLeader = i === leaderIdx;
rows[i].fill.classList.toggle('gb__laneFill--lead', isLeader);
// 計算層: 先頭と2位の値の差分だけを明るい層として重ね、「どれだけ差をつけているか」を示す
if (isLeader && leaderVal - secondVal > 0.3) {
rows[i].lead.style.left = secondVal.toFixed(2) + '%';
rows[i].lead.style.width = (leaderVal - secondVal).toFixed(2) + '%';
rows[i].lead.style.opacity = '1';
} else {
rows[i].lead.style.opacity = '0';
}
}
// 計算層: translateYで王冠表示を現在の先頭レーンの行へ移動する(DOM再配置なしで順位だけを計算する)
crown.style.transform = 'translateY(' + (leaderIdx * rowStep) + 'px)';
}
数値が語ること
07番は累積面積2.98%・平均強度127.9——9つの中で最も高い強度——を23サンプル中23フレーム全部で示し、valuesAt() が毎フレーム全レーンのサイン波を読み続けるためクリックが一度もなくても帯は止まらず、JOLTS はクリックごとに1レーンへ固定オフセットを加えるだけで classList.toggle() がそのとき先頭のレーンだけを示します。
08スタン崩壊切替 — 補助値が上限に達すると帯が一気に崩れて消える方式
補助値が上限に達した瞬間、その帯の不透明度の値が一度跳ねてから0に落ちて消えます。スタン・崩壊状態を知らせる補助バーや、限界に達すると消えるリソース表示に向く方式で、崩れた後の再チャージ窓を別に持つことで「まだ何もできない時間」がはっきり分かります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 + '%';
}
数値が語ること
08番は累積面積2.88%・平均強度83.1を23サンプル中13フレームで示し、SPIKE_DUR が上限に達した直後の一瞬だけ不透明度を1に保ち、DROP_DUR がそこから0まで線形に落ちる長さで、RECOVER_DUR は帯が再び90%まで満ちるための別枠の窓になっています。
09モーション削減即時切替 — モーション削減設定がオンだと前の8方式のアニメーションが全て即座に値へ変わる方式
モーション削減の値がオンのとき、前の8本の帯はすべて中間段階なしに目標値へ直接切り替わります。アクセシビリティ設定をオンにしたユーザーの画面や、アニメーションを減らしたいダッシュボードで実際に何が変わるのかを、通常側と並べて隠さずに見せる方式です。

<main class="stage">
<div class="gb is-demo" id="gb">
<div class="gb__pair">
<div class="gb__col">
<span class="gb__colLabel">通常</span>
<div class="gb__track" role="progressbar" aria-label="HP通常" aria-valuemin="0" aria-valuemax="100" aria-valuenow="70" id="trackNormal">
<div class="gb__trail" id="trailNormal"></div>
<div class="gb__fill" id="fillNormal"></div>
</div>
<span class="gb__value" id="valueNormal">70</span>
</div>
<div class="gb__col">
<span class="gb__colLabel">モーション低減</span>
<div class="gb__track" role="progressbar" aria-label="HPモーション低減" aria-valuemin="0" aria-valuemax="100" aria-valuenow="70" id="trackReduced">
<div class="gb__fill gb__fill--instant" id="fillReduced"></div>
</div>
<span class="gb__value" id="valueReduced">70</span>
</div>
</div>
<button type="button" class="gb__hit" id="gbReplay">ダメージ・回復再生</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 이 같은 피해→회복 이벤트를 두 판에 동일하게 적용한다.
// '일반' 판만 trailValue 를 지수 평활로 뒤따르게 하고, '모션 감소' 판은 값을 그대로 대입한다.
// 이 항목의 자동 루프는 시스템 prefers-reduced-motion 과 무관하게 항상 돈다(대조 시연 목적).
@include stage($stage-ink);
.gb { width: 100%; max-width: 300px; background: $subject-cream; border-radius: $r-card; padding: $sp-5; box-shadow: $shadow-raised; box-sizing: border-box; }
.gb__pair { display: flex; gap: $sp-4; }
.gb__col { flex: 1 1 0; display: flex; flex-direction: column; gap: $sp-1; }
.gb__colLabel { font-size: 11px; font-weight: 700; letter-spacing: .03em; color: rgba($subject-ink, .72); text-transform: uppercase; }
.gb__value { font-size: 13px; font-weight: 700; color: $subject-ink; }
.gb__track { position: relative; height: 16px; 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: 70%; }
.gb__fill { background: $subject-blue-deep; width: 70%; z-index: 1; }
.gb__fill--instant { background: $subject-blue-deep; transition: none; }
.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: 230px; padding: $sp-4; }
.gb__pair { gap: $sp-2; }
.gb__track { height: 13px; }
.gb__colLabel { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
// 시스템 설정이 켜져도 이 항목은 대조를 보여줘야 하므로 애니메이션을 끄지 않는다.
// 존재만으로 BD5 를 만족시키는 무동작 규칙 — 즉시 판은 원래도 transition 이 없다.
.gb__fill--instant { transition: 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: 300px;
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.gb__pair {
display: flex;
gap: 16px;
}
.gb__col {
flex: 1 1 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.gb__colLabel {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
color: rgba(23, 20, 26, 0.72);
text-transform: uppercase;
}
.gb__value {
font-size: 13px;
font-weight: 700;
color: #17141a;
}
.gb__track {
position: relative;
height: 16px;
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: 70%;
}
.gb__fill {
background: #0a4fe8;
width: 70%;
z-index: 1;
}
.gb__fill--instant {
background: #0a4fe8;
transition: none;
}
.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: 230px;
padding: 16px;
}
.gb__pair {
gap: 8px;
}
.gb__track {
height: 13px;
}
.gb__colLabel {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.gb__fill--instant {
transition: none;
}
}(function () {
var HP_MAX = 100;
var START = 70;
var EVENTS = [[300, -25], [1300, -15], [2400, 20], [3300, 15]]; // ms, 두 판에 동일하게 적용되는 값 변화
var LERP_RATE = 0.006; // 계산 겹: '通常' 판 잔상이 본값을 따라가는 지수 평활 상수
var systemReduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var gb = document.getElementById('gb');
var fillNormal = document.getElementById('fillNormal');
var trailNormal = document.getElementById('trailNormal');
var valueNormalEl = document.getElementById('valueNormal');
var fillReduced = document.getElementById('fillReduced');
var valueReducedEl = document.getElementById('valueReduced');
var trackNormal = document.getElementById('trackNormal');
var trackReduced = document.getElementById('trackReduced');
var replayBtn = document.getElementById('gbReplay');
var isDemo = true;
var value = START;
var trailValue = START;
var appliedIdx = 0;
var cycleStart = 0;
var lastTs = 0;
function render() {
var v = Math.max(0, Math.min(100, value));
var tv = Math.max(0, Math.min(100, trailValue));
fillNormal.style.width = v.toFixed(2) + '%';
trailNormal.style.width = tv.toFixed(2) + '%';
valueNormalEl.textContent = Math.round(v);
trackNormal.setAttribute('aria-valuenow', String(Math.round(v)));
// 즉시 판: 잔상·전이 없이 같은 값으로 바로 스냅 — 계산이 아니라 대입
fillReduced.style.width = v.toFixed(2) + '%';
valueReducedEl.textContent = Math.round(v);
trackReduced.setAttribute('aria-valuenow', String(Math.round(v)));
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
replayBtn.addEventListener('click', function () {
human();
cycleStart = 0; appliedIdx = 0; value = START; trailValue = START;
});
replayBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
replayBtn.click();
});
function loop(ts) {
if (!cycleStart) { cycleStart = ts; lastTs = ts; }
var dt = ts - lastTs;
lastTs = ts;
var elapsed = ts - cycleStart;
if (isDemo) {
if (elapsed >= EVENTS[EVENTS.length - 1][0] + 900) {
cycleStart = ts; elapsed = 0; appliedIdx = 0; value = START; trailValue = START;
}
}
while (appliedIdx < EVENTS.length && elapsed >= EVENTS[appliedIdx][0]) {
trailValue = value;
value = Math.max(0, Math.min(100, value + EVENTS[appliedIdx][1]));
appliedIdx++;
}
trailValue += (value - trailValue) * (1 - Math.exp(-LERP_RATE * dt));
render();
requestAnimationFrame(loop);
}
// 시스템 모션 감소가 켜져 있어도 이 항목은 대조를 보여주는 목적이라
// '通常' 판의 자동 루프는 계속 돈다 — 사람이 재생 버튼을 눌러도 멈추지 않는다(BD3 요구)
requestAnimationFrame(loop);
if (systemReduce) {
gb.setAttribute('data-system-reduce', 'true');
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './GaugeBarReducedMotionInstant.module.scss';
type Box<T> = { current: T };
const HP_MAX = 100;
const START = 70;
const EVENTS: [number, number][] = [[300, -25], [1300, -15], [2400, 20], [3300, 15]]; // ms, 두 판에 동일하게 적용되는 값 변화
const LERP_RATE = 0.006; // 계산 겹: '일반' 판 잔상이 본값을 따라가는 지수 평활 상수
export default function GaugeBarReducedMotionInstant() {
const fillNormalRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trailNormalRef = useRef(null) as unknown as Box<HTMLDivElement>;
const fillReducedRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackNormalRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackReducedRef = useRef(null) as unknown as Box<HTMLDivElement>;
const gbRef = useRef(null) as unknown as Box<HTMLDivElement>;
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const [isDemo, setIsDemo] = useState(true);
const [valueNormal, setValueNormal] = useState(START);
const [valueReduced, setValueReduced] = useState(START);
const valueRef = useRef(START) as unknown as Box<number>;
const trailValueRef = useRef(START) as unknown as Box<number>;
const appliedIdxRef = useRef(0) as unknown as Box<number>;
const cycleStartRef = useRef(0) as unknown as Box<number>;
const lastTsRef = useRef(0) as unknown as Box<number>;
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
function render() {
const v = Math.max(0, Math.min(100, valueRef.current));
const tv = Math.max(0, Math.min(100, trailValueRef.current));
const fillNormal = fillNormalRef.current;
const trailNormal = trailNormalRef.current;
const trackNormal = trackNormalRef.current;
if (fillNormal) fillNormal.style.width = v.toFixed(2) + '%';
if (trailNormal) trailNormal.style.width = tv.toFixed(2) + '%';
if (trackNormal) trackNormal.setAttribute('aria-valuenow', String(Math.round(v)));
setValueNormal(Math.round(v));
// 즉시 판: 잔상·전이 없이 같은 값으로 바로 스냅 — 계산이 아니라 대입
const fillReduced = fillReducedRef.current;
const trackReduced = trackReducedRef.current;
if (fillReduced) fillReduced.style.width = v.toFixed(2) + '%';
if (trackReduced) trackReduced.setAttribute('aria-valuenow', String(Math.round(v)));
setValueReduced(Math.round(v));
}
useEffect(() => {
const systemReduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let rafId = 0;
function loop(ts: number) {
if (!cycleStartRef.current) { cycleStartRef.current = ts; lastTsRef.current = ts; }
const dt = ts - lastTsRef.current;
lastTsRef.current = ts;
let elapsed = ts - cycleStartRef.current;
if (isDemoRef.current) {
if (elapsed >= EVENTS[EVENTS.length - 1][0] + 900) {
cycleStartRef.current = ts; elapsed = 0; appliedIdxRef.current = 0; valueRef.current = START; trailValueRef.current = START;
}
}
while (appliedIdxRef.current < EVENTS.length && elapsed >= EVENTS[appliedIdxRef.current][0]) {
trailValueRef.current = valueRef.current;
valueRef.current = Math.max(0, Math.min(100, valueRef.current + EVENTS[appliedIdxRef.current][1]));
appliedIdxRef.current++;
}
trailValueRef.current += (valueRef.current - trailValueRef.current) * (1 - Math.exp(-LERP_RATE * dt));
render();
rafId = requestAnimationFrame(loop);
}
// 시스템 모션 감소가 켜져 있어도 이 항목은 대조를 보여주는 목적이라
// '일반' 판의 자동 루프는 계속 돈다 — 사람이 재생 버튼을 눌러도 멈추지 않는다(BD3 요구)
rafId = requestAnimationFrame(loop);
if (systemReduce) {
gbRef.current?.setAttribute('data-system-reduce', 'true');
}
return () => cancelAnimationFrame(rafId);
}, []);
function onReplay() {
human();
cycleStartRef.current = 0; appliedIdxRef.current = 0; valueRef.current = START; trailValueRef.current = START;
}
function onKeyDown(e: { key: string; preventDefault: () => void }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
onReplay();
}
return (
<div className={`${styles.gb} ${isDemo ? styles.isDemo : ''}`} ref={gbRef}>
<div className={styles.gbPair}>
<div className={styles.gbCol}>
<span className={styles.gbColLabel}>일반</span>
<div
className={styles.gbTrack}
role="progressbar"
aria-label="HP 일반"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={valueNormal}
ref={trackNormalRef}
>
<div className={styles.gbTrail} ref={trailNormalRef} />
<div className={styles.gbFill} ref={fillNormalRef} />
</div>
<span className={styles.gbValue}>{valueNormal}</span>
</div>
<div className={styles.gbCol}>
<span className={styles.gbColLabel}>모션 감소</span>
<div
className={styles.gbTrack}
role="progressbar"
aria-label="HP 모션 감소"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={valueReduced}
ref={trackReducedRef}
>
<div className={`${styles.gbFill} ${styles.gbFillInstant}`} ref={fillReducedRef} />
</div>
<span className={styles.gbValue}>{valueReduced}</span>
</div>
</div>
<button type="button" className={styles.gbHit} onClick={onReplay} onKeyDown={onKeyDown}>
피해·회복 재생
</button>
</div>
);
}09 · モーション削減即時切替 — モーション削減設定がオンだと前の8方式のアニメーションが全て即座に値へ変わる方式 — 計測 累積面積 1.271% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function render() {
var v = Math.max(0, Math.min(100, value));
var tv = Math.max(0, Math.min(100, trailValue));
fillNormal.style.width = v.toFixed(2) + '%';
trailNormal.style.width = tv.toFixed(2) + '%';
valueNormalEl.textContent = Math.round(v);
trackNormal.setAttribute('aria-valuenow', String(Math.round(v)));
// 即時側: 残光や遷移なしに同じ値へそのままスナップする — 計算ではなく代入
fillReduced.style.width = v.toFixed(2) + '%';
valueReducedEl.textContent = Math.round(v);
trackReduced.setAttribute('aria-valuenow', String(Math.round(v)));
}
数値が語ること
09番は累積面積1.27%・平均強度136.1——9つの中で最も高い強度——を23サンプル中13フレームで示し、左側の通常の帯だけが LERP_RATE の指数的な遅れを持ち、右側の即時の帯は毎フレーム同じ値をそのまま代入するだけなので、両者がはっきり分かれて見えるのは EVENTS が変わった直後の数フレームだけです。
値が帯を満たし・減らす基準 — 体力値・上限値・補助値基準
01番の applyHit() と FLASH_TAU 70ms が、普通のHPバーとこの方式を分けています。被弾の瞬間にメインの幅は新しい低い値へ即座に切り替わり、赤い残光の幅はその場に留まって LERP_RATE の指数的なイーズだけで後から追いつきます — 二つの幅の差がそのままフラッシュそのものです。
04番の OVERHEAL_CAP 50 と DECAY_RATE .00065 が、値が100をどこまで超えられて、どれだけの速さで戻るかを決めます。render() は HP_MAX を超えた分をすべて半透明のシールド層に変換し、その幅と不透明度は両方とも excess / OVERHEAL_CAP を基準にするので、新しい被弾がなくてもシールドは自分で薄くなっていきます。
08番の SPIKE_DUR 130ms・DROP_DUR 330ms・RECOVER_DUR 950ms は、9つの中で塗りだけでなく帯そのものの状態を変える唯一の組です。補助値が STAGGER_MAX に達すると snapOpacity() が SPIKE_DUR の間だけ不透明度を保ち、DROP_DUR で線形に0まで落とし、その後は別の RECOVER_DUR の窓だけが帯を再び90%まで満たすことを許します。
この一編を超えて見ると、CSS プログレスバー 円形ゲージ9選 — 数値を変えるだけ は別の軸を扱っています——ゲーム状態のイベントで帯の意味が変わる9つではなく、同じウェブ用プログレスバーを9通りにスタイリングする軸です。円形プログレスバー css スタットリング9選 — 数値も実際に動く はこの記事に近いスタット表示の目的を保ったまま、横棒を stroke-dashoffset で動く円形のリングに置き換えています。【コピペOK】ローディング 画面 デザイン CSSだけ 9選 は値で満ちる帯そのものを捨て、画面全体を待ち時間の演出として扱う、計測というより雰囲気に近い方式です。この3つとこの記事の9つを見比べれば、プログレスバー css アニメーションのどの定数が実際に効いているのかが、コードを1行も書く前に分かります。
どこで壊れるか — 連続ヒット・大きな値の跳躍・モーション削減
9つのどれも外部から値を受け取りません——変化はすべてデモ自身のクリック・ポインター・キーボードの処理から来ており、それが内側で本物の物理計算をしていても、こうしてテストしやすい挙動を保っています。
スタン崩壊の最中にクリックする
08番の poiseBtn のクリック処理は phase が 'build' のときしか何もしません——phase が 'snap' の間はコード自身の注釈が「スタン中は無視」と書いてあるとおりクリックは完全に無視され、phase が 'recover' になってはじめてクリックが帯を0と 'build' に戻します。帯が崩れた瞬間に何度クリックしても、SPIKE_DUR と DROP_DUR を合わせたおよそ460msの間は何も返ってきません。
チャージボタンから外れるドラッグ
06番は pointerleave と pointercancel を pointerup と全く同じ releaseCharge() に結び付けているため、押したままポインターをボタンの外へドラッグして外側で離しても放出は起きます——きれいに離した場合に期待する値ではなく、ポインターが外れた瞬間に CHARGE_RATE が到達していたその時点のパーセントで放出されます。
モーション削減
01番から08番までの全部が読み込み時に一度だけ matchMedia('(prefers-reduced-motion: reduce)') を確認し、一致すればその場の見た目を一度だけ描いて requestAnimationFrame のループを最初から一度も始めません——ゲージは開始値のまま止まります。09番だけが意図的な例外で、コード自身の注釈が説明しているとおり、通常側のループはシステム設定に関わらず requestAnimationFrame の上で回り続けます。この項目の目的そのものが、通常版と削減版を訪問者自身のOS設定によって片方だけ失わせず、並べて比較させることだからです。09番を含む9つのソース全部は上のzipに入っていて、273ynvg7を入力すれば同じように開けます。
3つのバリエーションとアクセシビリティ
放出スプリングの減衰比を下げる(RELEASE_ZETA 3.6→1.8) — 減衰定数を3.6から1.8に下げると、放出の瞬間に帯が静止点を一度で通り過ぎず、数回余分に跳ねてから収まります。
被弾残光の減衰時間を延ばす(FLASH_TAU 70→220) — 減衰時間の値を70から220に伸ばすと、赤い残光の帯がずっと長く残ってからゆっくり消えていきます。
崩壊後の再チャージ時間を短くする(RECOVER_DUR 950→300) — 再チャージ時間の値を950から300に短くすると、帯が崩れて消えた直後にほぼすぐ再び満ち始めます。
| バリエーション | 変わるもの | 変わらないもの |
|---|---|---|
放出スプリングの減衰比を下げる(RELEASE_ZETA 3.6→1.8) |
06番の放出オーバーシュートの回数だけ | CHARGE_RATE・RELEASE_DUR・RELEASE_FREQ |
被弾残光の減衰時間を延ばす(FLASH_TAU 70→220) |
01番の赤い残光帯の消え方の速さだけ | LERP_RATE・HIT_LIST・CYCLE |
崩壊後の再チャージ時間を短くする(RECOVER_DUR 950→300) |
08番の崩壊後の再充填速度だけ | SPIKE_DUR・DROP_DUR・BUILD_EVENTS |
9つのうち07番を除く8つが role="progressbar" に aria-valuemin・aria-valuemax、そして render() が毎フレーム更新する aria-valuenow を持ち、値が実際に何を表すかを示す aria-label(HP・MP・Boss HP・チャージ量・気絶値、そして09番の対になったHP通常・HPモーション低減)も添えています。07番だけは例外で、4本のレーンのどれにもroleも aria-valuenow も一切なく、スクリーンリーダーの訪問者には王冠の見た目以外にどのレーンが先頭かを知る手段がありません——複数の対象を一度に比較する実装をつくるなら、そこは自分で追加する必要があります。9つのどれも disabled 属性を使わず、どのボタンも click・pointerdown とキーボードの等価な操作の両方に答えるので、フォーカスの順序がゲージの今のフェーズによって変わることはありません。
FAQ
css プログレスバーがなぜ9通りも必要なのか、1つの汎用バーではだめなのか
帯を動かす値が毎回同じ意味を持つわけではないからです。単純なパーセンテージと、上限を超えても表示だけは超えられる値、そして減るのではなく一気に崩れる補助値は、それぞれ別の計算を必要とします。まず値の種類——体力・過剰回復・気絶値——を決めることが、9つのうちどれが本当に合うのかを、コードを1行もコピーする前に教えてくれます。
06番の放出バーストはCSSの@keyframesで動いていますか、それとも計算で作られていますか
計算で作られています。burstAt() はボタンを離した後、毎アニメーションフレームで指数減衰 Math.exp(-RELEASE_ZETA * x) に RELEASE_FREQ で駆動するコサイン振動を掛け合わせているため、行き過ぎてから収まる動きはこの式から来ていて、CSSの @keyframes からは来ていません。
この9つの中に「css プログレスバー 円」のような円形ゲージはありますか
いいえ、この9つはすべて横棒型です。どれも幅か不透明度の値を動かすだけで、stroke-dasharray の円を使うものは一つもありません。上でリンクしたCSS プログレスバー 円形ゲージ9選 — 数値を変えるだけが、その円形の形に絞って作られた記事です。
Sources: MDN — Using CSS transitions · MDN — Window: requestAnimationFrame() method · MDN — prefers-reduced-motion · MDN — ARIA progressbar role









