Fuse
import { useEffect, useId, useRef, useState } from 'react';
const W = 240;
const Y = 22;
const at = (v: number) => 6 + (v / 100) * (W - 12);
type Ember = { x: number; y: number; vx: number; vy: number; life: number; max: number };
const css = `
.slider-fuse { position: relative; width: ${W}px; height: 66px; }
.slider-fuse__svg { position: absolute; left: 0; top: 0; width: ${W}px; height: 44px; overflow: visible; }
.slider-fuse__cord { fill: light-dark(#50535b, #c6c8cd); }
.slider-fuse__twist { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.3; }
.slider-fuse__ash { fill: none; stroke-width: 2; stroke-linecap: round; stroke-dasharray: 5 2 1.5 2.5; }
.slider-fuse__hot { stop-color: light-dark(#42454c, #e5e6e9); }
.slider-fuse__cold { stop-color: light-dark(#cbced3, #393c41); }
.slider-fuse__halo { stop-color: light-dark(#373a40, #f1f2f3); }
.slider-fuse__core, .slider-fuse__ember { fill: light-dark(#373a40, #f1f2f3); }
.slider-fuse__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-fuse:has(.slider-fuse__input:focus-visible) .slider-fuse__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-fuse__value { position: absolute; left: 0; right: 0; top: 46px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-fuse__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-fuse__input { position: absolute; left: 0; top: 0; width: 100%; height: 44px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-fuse__input::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 44px; }
.slider-fuse__input::-moz-range-thumb { width: 12px; height: 44px; border: 0; }
`;
export function FuseSlider() {
const [v, setV] = useState(64);
const [embers, setEmbers] = useState<Ember[]>([]);
const [flick, setFlick] = useState(1);
const id = useId().replace(/[^a-zA-Z0-9]/g, '');
const box = useRef<HTMLDivElement>(null);
const st = useRef({ x: at(64), raf: 0, acc: 0, list: [] as Ember[] });
const sx = at(v);
// The spark fizzes on its own and sprays harder the faster it is dragged. Paused off screen.
useEffect(() => {
const q = st.current;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let last = 0;
let prev = q.x;
const step = (now: number) => {
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016;
last = now;
const moved = q.x - prev;
prev = q.x;
const speed = Math.min(500, Math.abs(moved) / dt);
q.acc += dt * (8 + speed * 0.18);
while (q.acc >= 1) {
q.acc -= 1;
if (q.list.length > 70) continue;
const a = -Math.PI * (0.1 + Math.random() * 0.8);
const s = 35 + Math.random() * 75;
const max = 0.3 + Math.random() * 0.45;
q.list.push({ x: q.x, y: Y, vx: Math.cos(a) * s - Math.sign(moved) * speed * 0.15, vy: Math.sin(a) * s, life: max, max });
}
q.list = q.list.filter((e) => (e.life -= dt) > 0);
for (const e of q.list) {
e.vy += 240 * dt;
e.x += e.vx * dt;
e.y += e.vy * dt;
}
setEmbers(q.list.slice());
setFlick(0.8 + Math.random() * 0.4);
q.raf = requestAnimationFrame(step);
};
const io = new IntersectionObserver(([e]) => {
cancelAnimationFrame(q.raf);
last = 0;
if (e.isIntersecting) q.raf = requestAnimationFrame(step);
});
io.observe(box.current!);
return () => {
io.disconnect();
cancelAnimationFrame(q.raf);
};
}, []);
return (
<>
<style>
{css}
</style>
<div className="slider-fuse" ref={box}>
<svg className="slider-fuse__svg" aria-hidden="true">
<defs>
{/* A twisted cord: a stripe pattern skewed into diagonal turns. */}
<pattern id={`${id}p`} patternUnits="userSpaceOnUse" width={4} height={6} patternTransform="skewX(-38)">
<rect className="slider-fuse__cord" width={4} height={6} />
<line className="slider-fuse__twist" x1={0.6} y1={0} x2={0.6} y2={6} />
</pattern>
<linearGradient id={`${id}a`} gradientUnits="userSpaceOnUse" x1={sx} x2={sx + 26} y1={0} y2={0}>
<stop className="slider-fuse__hot" offset="0" />
<stop className="slider-fuse__cold" offset="1" />
</linearGradient>
<radialGradient id={`${id}h`}>
<stop className="slider-fuse__halo" offset="0" stopOpacity={0.55} />
<stop className="slider-fuse__halo" offset="1" stopOpacity={0} />
</radialGradient>
</defs>
<line className="slider-fuse__ash" x1={sx} y1={Y} x2={W - 3} y2={Y} stroke={`url(#${id}a)`} />
<rect className="slider-fuse__rope" x={3} y={Y - 3} width={Math.max(0, sx - 3)} height={6} rx={3} fill={`url(#${id}p)`} />
<circle className="slider-fuse__glow" cx={sx} cy={Y} r={11 * flick} fill={`url(#${id}h)`} />
{embers.map((e, i) => (
<circle key={i} className="slider-fuse__ember" cx={e.x} cy={e.y} r={1.2} opacity={e.life / e.max} />
))}
<circle className="slider-fuse__core" cx={sx} cy={Y} r={2.8} />
<circle className="slider-fuse__ring" cx={sx} cy={Y} r={9} />
</svg>
<p className="slider-fuse__value" aria-hidden="true">
<span>Delay</span>
<b>{(v / 10).toFixed(1)} s</b>
</p>
<input
className="slider-fuse__input"
type="range"
min={0}
max={100}
value={v}
onChange={(e) => {
setV(+e.target.value);
st.current.x = at(+e.target.value);
}}
aria-label="Delay"
aria-valuetext={`${(v / 10).toFixed(1)} seconds`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderFuse(el) on the mounted element instead.
<div class="slider-fuse">
<svg class="slider-fuse__svg" aria-hidden="true">
<defs>
<pattern id="slider-fuse-cord" patternUnits="userSpaceOnUse" width="4" height="6" patternTransform="skewX(-38)">
<rect class="slider-fuse__cord" width="4" height="6"></rect>
<line class="slider-fuse__twist" x1="0.6" y1="0" x2="0.6" y2="6"></line>
</pattern>
<linearGradient id="slider-fuse-ash" gradientUnits="userSpaceOnUse" x1="151.92" x2="177.92" y1="0" y2="0">
<stop class="slider-fuse__hot" offset="0"></stop>
<stop class="slider-fuse__cold" offset="1"></stop>
</linearGradient>
<radialGradient id="slider-fuse-halo">
<stop class="slider-fuse__halo" offset="0" stop-opacity="0.55"></stop>
<stop class="slider-fuse__halo" offset="1" stop-opacity="0"></stop>
</radialGradient>
</defs>
<line class="slider-fuse__ash" x1="151.92" y1="22" x2="237" y2="22" stroke="url(#slider-fuse-ash)"></line>
<rect class="slider-fuse__rope" x="3" y="19" width="148.92" height="6" rx="3" fill="url(#slider-fuse-cord)"></rect>
<circle class="slider-fuse__glow" cx="151.92" cy="22" r="11" fill="url(#slider-fuse-halo)"></circle>
<circle class="slider-fuse__core" cx="151.92" cy="22" r="2.8"></circle>
<circle class="slider-fuse__ring" cx="151.92" cy="22" r="9"></circle>
</svg>
<p class="slider-fuse__value" aria-hidden="true">
<span>Delay</span>
<b>6.4 s</b>
</p>
<input class="slider-fuse__input" type="range" min="0" max="100" aria-label="Delay" aria-valuetext="6.4 seconds" value="64"/>
</div>.slider-fuse { position: relative; width: 240px; height: 66px; }
.slider-fuse__svg { position: absolute; left: 0; top: 0; width: 240px; height: 44px; overflow: visible; }
.slider-fuse__cord { fill: light-dark(#50535b, #c6c8cd); }
.slider-fuse__twist { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.3; }
.slider-fuse__ash { fill: none; stroke-width: 2; stroke-linecap: round; stroke-dasharray: 5 2 1.5 2.5; }
.slider-fuse__hot { stop-color: light-dark(#42454c, #e5e6e9); }
.slider-fuse__cold { stop-color: light-dark(#cbced3, #393c41); }
.slider-fuse__halo { stop-color: light-dark(#373a40, #f1f2f3); }
.slider-fuse__core, .slider-fuse__ember { fill: light-dark(#373a40, #f1f2f3); }
.slider-fuse__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-fuse:has(.slider-fuse__input:focus-visible) .slider-fuse__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-fuse__value { position: absolute; left: 0; right: 0; top: 46px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-fuse__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-fuse__input { position: absolute; left: 0; top: 0; width: 100%; height: 44px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-fuse__input::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 44px; }
.slider-fuse__input::-moz-range-thumb { width: 12px; height: 44px; border: 0; }function initSliderFuse(root) {
var W = 240;
var Y = 22;
var NS = 'http://www.w3.org/2000/svg';
var input = root.querySelector('.slider-fuse__input');
var svg = root.querySelector('.slider-fuse__svg');
var ash = root.querySelector('.slider-fuse__ash');
var grad = root.querySelector('#slider-fuse-ash');
var rope = root.querySelector('.slider-fuse__rope');
var glow = root.querySelector('.slider-fuse__glow');
var core = root.querySelector('.slider-fuse__core');
var ring = root.querySelector('.slider-fuse__ring');
var label = root.querySelector('.slider-fuse__value b');
var embers = [];
var x = at(+input.value);
var prev = x;
var acc = 0;
var last = 0;
var raf = 0;
function at(v) { return 6 + (v / 100) * (W - 12); }
function onInput() {
var s = (+input.value / 10).toFixed(1);
x = at(+input.value);
label.textContent = s + ' s';
input.setAttribute('aria-valuetext', s + ' seconds');
ash.setAttribute('x1', x);
grad.setAttribute('x1', x);
grad.setAttribute('x2', x + 26);
rope.setAttribute('width', Math.max(0, x - 3));
[glow, core, ring].forEach(function (c) { c.setAttribute('cx', x); });
}
// The spark fizzes on its own and sprays harder the faster it is dragged.
function step(now) {
var dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016;
last = now;
var moved = x - prev;
prev = x;
var speed = Math.min(500, Math.abs(moved) / dt);
acc += dt * (8 + speed * 0.18);
while (acc >= 1) {
acc -= 1;
if (embers.length > 70) continue;
var a = -Math.PI * (0.1 + Math.random() * 0.8);
var s = 35 + Math.random() * 75;
var max = 0.3 + Math.random() * 0.45;
var el = document.createElementNS(NS, 'circle');
el.setAttribute('class', 'slider-fuse__ember');
el.setAttribute('r', '1.2');
svg.insertBefore(el, core);
embers.push({ el: el, x: x, y: Y, vx: Math.cos(a) * s - Math.sign(moved) * speed * 0.15, vy: Math.sin(a) * s, life: max, max: max });
}
embers = embers.filter(function (e) {
e.life -= dt;
if (e.life <= 0) {
e.el.remove();
return false;
}
e.vy += 240 * dt;
e.x += e.vx * dt;
e.y += e.vy * dt;
e.el.setAttribute('cx', e.x);
e.el.setAttribute('cy', e.y);
e.el.setAttribute('opacity', e.life / e.max);
return true;
});
glow.setAttribute('r', 11 * (0.8 + Math.random() * 0.4));
raf = requestAnimationFrame(step);
}
// Only runs while on screen, and not at all under reduced motion.
var io = new IntersectionObserver(function (entries) {
cancelAnimationFrame(raf);
last = 0;
if (entries[0].isIntersecting && !matchMedia('(prefers-reduced-motion: reduce)').matches) raf = requestAnimationFrame(step);
});
io.observe(root);
input.addEventListener('input', onInput);
return function () {
io.disconnect();
cancelAnimationFrame(raf);
input.removeEventListener('input', onInput);
};
}
document.querySelectorAll('.slider-fuse').forEach(initSliderFuse);Goal: Create a "Fuse" slider.
Visual behavior: A delay slider drawn as a burning fuse: the cord still to burn is the fill, ash lies ahead of the thumb, and the spark at the thumb fizzes and spits more embers the faster you drag.
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.
- The cord is a rect filled with a stripe pattern skewed into diagonal turns; the ash ahead is a broken dashed line whose gradient starts hot at the spark and cools to grey.
- Embers are small circles spawned at the spark in requestAnimationFrame: a steady rate at rest plus more with drag speed, each thrown upward against the drag, pulled down by gravity and fading over half a second. The halo flickers in size. The loop pauses off screen through an IntersectionObserver.
Accessibility: A native range input with an aria-label and an aria-valuetext in seconds; keyboard focus rings the spark. Under prefers-reduced-motion there are no embers and the spark stays still.
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 "Fuse" slider.
Visual behavior: A delay slider drawn as a burning fuse: the cord still to burn is the fill, ash lies ahead of the thumb, and the spark at the thumb fizzes and spits more embers the faster you drag.
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.
- The cord is a rect filled with a stripe pattern skewed into diagonal turns; the ash ahead is a broken dashed line whose gradient starts hot at the spark and cools to grey.
- Embers are small circles spawned at the spark in requestAnimationFrame: a steady rate at rest plus more with drag speed, each thrown upward against the drag, pulled down by gravity and fading over half a second. The halo flickers in size. The loop pauses off screen through an IntersectionObserver.
Accessibility: A native range input with an aria-label and an aria-valuetext in seconds; keyboard focus rings the spark. Under prefers-reduced-motion there are no embers and the spark stays still.
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 delay slider drawn as a burning fuse: the cord still to burn is the fill, ash lies ahead of the thumb, and the spark at the thumb fizzes and spits more embers the faster you drag.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- state change, continuous, ambient
- Techniques
- svg, raf, gradient
- 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 in seconds; keyboard focus rings the spark. Under prefers-reduced-motion there are no embers and the spark stays still.