04過剰回復シールド重ね切替 — 値が上限を超えるとその超過分が半透明の層として帯の上に重なる方式
値が上限値を超えた瞬間、その差分の幅が半透明の層として既存の帯の上に重なって現れます。
使いどころシールドが重なった体力表示、上限を超えたリソースバー
render()OVERHEAL_CAPDECAY_RATE

<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 に同梱 · css プログレスバー 9選 — ゲーム風に満ちる9つの方式
このデモが属する記事
css プログレスバー 9選 — ゲーム風に満ちる9つの方式css プログレスバー:9つのうち1つは値が減ると帯が先に狭まり赤い残光が後を追って縮み、1つは値が上限を超えるとその超過分が半透明の層として重なり、もう1つは補助値が上限に達すると帯の不透明度の値が跳ねてから消えます。 この記事のデモをすべて見る


