05위험 구간 박동 전환 — 값이 정해둔 낮은 구간에 들어가면 막대 전체가 일정한 리듬으로 밝아지고 어두워지는 방식
값이 정해둔 낮은 경계 밑으로 내려가면 막대의 밝기 값이 정해진 리듬으로 오르내리기 시작합니다.
쓰임체력이 낮을 때의 경고 표시, 남은 시간이 얼마 없을 때의 알림 막대
pulseFor()THRESHOLDPERIOD_MIN

<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 원작 · zip 에 SCSS·React 포함 · 게이지 바 9종 — 게임처럼 채워지는 아홉 가지 방식
이 데모가 속한 글
게이지 바 9종 — 게임처럼 채워지는 아홉 가지 방식게이지 바는 값이 오르내릴 때마다 채워진 폭이 바뀌는 막대인데, 아홉 개 중 하나는 값이 줄면 막대가 먼저 좁아지고 그 뒤로 붉은 잔상이 서서히 따라 줄어들며, 하나는 값이 상한을 넘으면 초과분이 반투명한 겹으로 얹 이 글의 데모 전부 보기


