06チャージ保持放出切替 — 押している間に値がゆっくり満ち、離した瞬間に一度に放出される方式
押している間に値が定めた速さで満ち、離した瞬間にその値が0まで一気に落ち、別の放出表示が点灯します。
使いどころ強攻撃チャージ表示、溜めてから放つリソースバー
burstAt()RELEASE_ZETACHARGE_RATE

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

