02回復ティック発光切替 — 値が一定間隔で少しずつ上がるたび帯の先端が光る方式
値が短い間隔で一段ずつ上がるたび、帯の先端の明るさの値が一瞬強くなってから落ち着きます。
使いどころ自動回復状態の体力・気力表示、待機中のリソース充填バー
tick()GLOW_TAUTICK_INTERVAL

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


