Drain
import { useRef, useState } from 'react';
const W = 240;
const SEGMENTS = 10;
const GAP = 2;
const PERIOD = (W + GAP) / SEGMENTS;
const css = `
.slider-drain { position: relative; width: ${W}px; }
.slider-drain__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-drain__head b { font-weight: 500; font-variant-numeric: tabular-nums; color: light-dark(#42454c, #e5e6e9); }
.slider-drain__bar {
position: relative;
height: 12px;
border-radius: 3px;
-webkit-mask-image: repeating-linear-gradient(90deg, #000 0 ${PERIOD - GAP}px, transparent ${PERIOD - GAP}px ${PERIOD}px);
mask-image: repeating-linear-gradient(90deg, #000 0 ${PERIOD - GAP}px, transparent ${PERIOD - GAP}px ${PERIOD}px);
}
.slider-drain__track, .slider-drain__ghost, .slider-drain__main, .slider-drain__flash { position: absolute; top: 0; bottom: 0; left: 0; }
.slider-drain__track { right: 0; background: light-dark(#ebedf0, #232529); }
.slider-drain__ghost { background: light-dark(#6d7179, #7d8087); }
.slider-drain__main { background: light-dark(#373a40, #f1f2f3); }
.slider-drain__flash { background: light-dark(#373a40, #f1f2f3); animation: slider-drain-flash 0.45s ease-out forwards; }
@keyframes slider-drain-flash {
from { opacity: 1; }
to { opacity: 0; }
}
.slider-drain__handle {
position: absolute;
bottom: -6px;
left: calc(var(--slider-drain-v) * 1% - 2px);
width: 4px;
height: 24px;
border-radius: 2px;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 0 0 2px light-dark(#fbfcfd, #121417);
transition: transform 0.3s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-drain:has(.slider-drain__input:active) .slider-drain__handle { transform: scaleY(1.2); }
.slider-drain:has(.slider-drain__input:focus-visible) .slider-drain__handle { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.slider-drain__input { position: absolute; left: 0; bottom: -12px; width: 100%; height: 36px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-drain__input::-webkit-slider-thumb { -webkit-appearance: none; width: 2px; height: 36px; }
.slider-drain__input::-moz-range-thumb { width: 2px; height: 36px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-drain__ghost, .slider-drain__main, .slider-drain__handle { transition: none !important; }
.slider-drain__flash { display: none; }
}
`;
export function DrainSlider() {
const [hp, setHp] = useState({ v: 72, dir: 0 });
const [hit, setHit] = useState({ n: 0, from: 0, to: 0 });
const bar = useRef<HTMLDivElement>(null);
const { v, dir } = hp;
// Losing: the bar drops at once and the lost chunk drains after a beat. Gaining: the ghost leads and the bar fills up to it.
const main = dir < 0 ? 'width 0.06s linear' : 'width 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) 0.25s';
const ghost = dir < 0 ? 'width 0.55s cubic-bezier(0.4, 0, 0.2, 1) 0.45s' : 'width 0.06s linear';
return (
<>
<style>
{css}
</style>
<div className="slider-drain" style={{ '--slider-drain-v': v } as React.CSSProperties}>
<div className="slider-drain__head" aria-hidden="true">
<span>Health</span>
<span>
<b>{v}</b> / 100
</span>
</div>
<div ref={bar} className="slider-drain__bar" aria-hidden="true">
<span className="slider-drain__track" />
<span className="slider-drain__ghost" style={{ width: `${v}%`, transition: ghost }} />
<span className="slider-drain__main" style={{ width: `${v}%`, transition: main }} />
{hit.n > 0 && <span key={hit.n} className="slider-drain__flash" style={{ left: `${hit.to}%`, width: `${hit.from - hit.to}%` }} />}
</div>
<span className="slider-drain__handle" aria-hidden="true" />
<input
className="slider-drain__input"
type="range"
min={0}
max={100}
value={v}
onChange={(e) => {
const next = +e.target.value;
if (next < v) {
setHit((h) => ({ n: h.n + 1, from: v, to: next }));
// A big hit jolts the bar.
if (v - next >= 15 && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
bar.current?.animate(
[{ transform: 'none' }, { transform: 'translateX(-3px)' }, { transform: 'translateX(3px)' }, { transform: 'translateX(-1px)' }, { transform: 'none' }],
{ duration: 260, easing: 'ease-out' },
);
}
}
setHp({ v: next, dir: Math.sign(next - v) });
}}
aria-label="Health"
aria-valuetext={`${v} of 100`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderDrain(el) on the mounted element instead.
<div class="slider-drain" style="--slider-drain-v:72">
<div class="slider-drain__head" aria-hidden="true">
<span>Health</span>
<span>
<b>72</b>
/ 100
</span>
</div>
<div class="slider-drain__bar" aria-hidden="true">
<span class="slider-drain__track"></span>
<span class="slider-drain__ghost" style="width:72%;transition:width 0.06s linear"></span>
<span class="slider-drain__main" style="width:72%;transition:width 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) 0.25s"></span>
</div>
<span class="slider-drain__handle" aria-hidden="true"></span>
<input class="slider-drain__input" type="range" min="0" max="100" aria-label="Health" aria-valuetext="72 of 100" value="72"/>
</div>.slider-drain { position: relative; width: 240px; }
.slider-drain__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-drain__head b { font-weight: 500; font-variant-numeric: tabular-nums; color: light-dark(#42454c, #e5e6e9); }
.slider-drain__bar {
position: relative;
height: 12px;
border-radius: 3px;
-webkit-mask-image: repeating-linear-gradient(90deg, #000 0 22.2px, transparent 22.2px 24.2px);
mask-image: repeating-linear-gradient(90deg, #000 0 22.2px, transparent 22.2px 24.2px);
}
.slider-drain__track, .slider-drain__ghost, .slider-drain__main, .slider-drain__flash { position: absolute; top: 0; bottom: 0; left: 0; }
.slider-drain__track { right: 0; background: light-dark(#ebedf0, #232529); }
.slider-drain__ghost { background: light-dark(#6d7179, #7d8087); }
.slider-drain__main { background: light-dark(#373a40, #f1f2f3); }
.slider-drain__flash { background: light-dark(#373a40, #f1f2f3); animation: slider-drain-flash 0.45s ease-out forwards; }
@keyframes slider-drain-flash {
from { opacity: 1; }
to { opacity: 0; }
}
.slider-drain__handle {
position: absolute;
bottom: -6px;
left: calc(var(--slider-drain-v) * 1% - 2px);
width: 4px;
height: 24px;
border-radius: 2px;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 0 0 2px light-dark(#fbfcfd, #121417);
transition: transform 0.3s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-drain:has(.slider-drain__input:active) .slider-drain__handle { transform: scaleY(1.2); }
.slider-drain:has(.slider-drain__input:focus-visible) .slider-drain__handle { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.slider-drain__input { position: absolute; left: 0; bottom: -12px; width: 100%; height: 36px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-drain__input::-webkit-slider-thumb { -webkit-appearance: none; width: 2px; height: 36px; }
.slider-drain__input::-moz-range-thumb { width: 2px; height: 36px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-drain__ghost, .slider-drain__main, .slider-drain__handle { transition: none !important; }
.slider-drain__flash { display: none; }
}function initSliderDrain(root) {
var input = root.querySelector('.slider-drain__input');
var bar = root.querySelector('.slider-drain__bar');
var main = root.querySelector('.slider-drain__main');
var ghost = root.querySelector('.slider-drain__ghost');
var reading = root.querySelector('.slider-drain__head b');
var v = +input.value, flash;
function update() {
var next = +input.value;
if (next === v) return;
var losing = next < v;
if (losing) {
// The lost chunk flashes before it drains.
if (flash) flash.remove();
flash = document.createElement('span');
flash.className = 'slider-drain__flash';
flash.style.left = next + '%';
flash.style.width = v - next + '%';
bar.appendChild(flash);
// A big hit jolts the bar.
if (v - next >= 15 && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
bar.animate(
[{ transform: 'none' }, { transform: 'translateX(-3px)' }, { transform: 'translateX(3px)' }, { transform: 'translateX(-1px)' }, { transform: 'none' }],
{ duration: 260, easing: 'ease-out' }
);
}
}
// Losing: the bar drops at once and the lost chunk drains after a beat. Gaining: the ghost leads and the bar fills up to it.
main.style.transition = losing ? 'width 0.06s linear' : 'width 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) 0.25s';
ghost.style.transition = losing ? 'width 0.55s cubic-bezier(0.4, 0, 0.2, 1) 0.45s' : 'width 0.06s linear';
v = next;
main.style.width = v + '%';
ghost.style.width = v + '%';
root.style.setProperty('--slider-drain-v', v);
reading.textContent = v;
input.setAttribute('aria-valuetext', v + ' of 100');
}
input.addEventListener('input', update);
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-drain').forEach(initSliderDrain);Goal: Create a "Drain" slider.
Visual behavior: A segmented health bar: take some away and the lost chunk flashes and drains after a beat; add some and a ghost shows the new level while the bar fills up to it.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- Two fills sit under one mask that cuts the bar into ten segments. Losing, the main fill snaps down and the ghost behind it drains after a delay; gaining, the ghost jumps up and the main fill follows after a delay. Choosing each transition by direction is the whole effect.
- A flash element over the lost chunk is created again on each loss so its fade replays, and a big loss also jolts the bar with a short shake.
Accessibility: A native range input with an aria-label and an aria-valuetext of the value out of 100; keyboard focus rings the handle. Under prefers-reduced-motion the bar moves at once, with no flash or shake.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Drain" slider.
Visual behavior: A segmented health bar: take some away and the lost chunk flashes and drains after a beat; add some and a ghost shows the new level while the bar fills up to it.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- Two fills sit under one mask that cuts the bar into ten segments. Losing, the main fill snaps down and the ghost behind it drains after a delay; gaining, the ghost jumps up and the main fill follows after a delay. Choosing each transition by direction is the whole effect.
- A flash element over the lost chunk is created again on each loss so its fade replays, and a big loss also jolts the bar with a short shake.
Accessibility: A native range input with an aria-label and an aria-valuetext of the value out of 100; keyboard focus rings the handle. Under prefers-reduced-motion the bar moves at once, with no flash or shake.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A segmented health bar: take some away and the lost chunk flashes and drains after a beat; add some and a ghost shows the new level while the bar fills up to it.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, fill, delay stagger
- Techniques
- css transition, keyframes
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input with an aria-label and an aria-valuetext of the value out of 100; keyboard focus rings the handle. Under prefers-reduced-motion the bar moves at once, with no flash or shake.