03Segment-pip crack toggle — the bar splits into pips, and dropping past one pip leaves a crack pattern on it
The instant the value passes below one pip, that pip's crack-amount value increases while the pips beyond it lose their fill.
Best forA boss health bar's stage segments, a lives/charges pip display
stateFor()CRACK_DURSTAGGER

<main class="stage">
<div class="gb is-demo" id="gb">
<div class="gb__head">
<span class="gb__label">Boss HP</span>
<span class="gb__value" id="gbValue">5 / 5</span>
</div>
<div class="gb__track" id="gbTrack" role="progressbar" aria-label="Boss HP" aria-valuemin="0" aria-valuemax="5" aria-valuenow="5"></div>
<button type="button" class="gb__hit" id="gbBreak">Break Segment</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 Segment-pip crack toggle — the bar splits into pips, and dropping past one pip leaves a crack pattern on it — Measured area 1.917% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · CSS Progress Bar: 9 Game-Style Fill Patterns
The post this demo lives in
CSS Progress Bar: 9 Game-Style Fill PatternsCSS Progress Bar: One of nine narrows as its value drops while a red glow trails behind, one layers excess past its ceil See all demos in this post
If you like this effect - similar demos
Loaders04 A Loader That Splits the Logo Into Tiles and Flips Them in a Diagonal WaveLogo Loading Animation: 9 Progress-Driven Marks
Loaders05 Icon progress ring — an indicator whose circular progress value fills with the pulled distance and morphs into a checkmark once completePull to Refresh CSS: 9 Loading Patterns
Loaders06 Corruption-retry count value — the retry-count value climbs by one and the progress value resets to the start whenever verification failsDownload Progress Bar: 9 Loading UI Patterns