Cast
import { useEffect, useId, useRef, useState } from 'react';
const W = 240;
const G = 42;
const TICKS = Array.from({ length: 21 }, (_, i) => 10 + i * 11);
const at = (v: number) => 8 + (v / 100) * (W - 16);
const css = `
.slider-cast { position: relative; width: ${W}px; height: 76px; --slider-cast-shadow: light-dark(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.8)); }
.slider-cast__svg { position: absolute; left: 0; top: 0; width: ${W}px; height: 56px; overflow: visible; }
.slider-cast__ground { fill: light-dark(#ebedf0, #232529); }
.slider-cast__lit { fill: light-dark(#cbced3, #393c41); }
.slider-cast__shadow { stroke: var(--slider-cast-shadow); stroke-width: 2.4; stroke-linecap: round; }
.slider-cast__tick { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.6; stroke-linecap: round; }
.slider-cast__post { stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2; stroke-linecap: round; }
.slider-cast__bulb { fill: light-dark(#373a40, #f1f2f3); }
.slider-cast__glow { stop-color: light-dark(#373a40, #f1f2f3); }
.slider-cast__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-cast:has(.slider-cast__input:focus-visible) .slider-cast__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-cast__value { position: absolute; left: 0; right: 0; top: 58px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-cast__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-cast__input { position: absolute; left: 0; top: 0; width: 100%; height: 56px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-cast__input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 56px; }
.slider-cast__input::-moz-range-thumb { width: 16px; height: 56px; border: 0; }
`;
export function CastSlider() {
const [v, setV] = useState(62);
const [lx, setLx] = useState(at(62));
const id = useId().replace(/[^a-zA-Z0-9]/g, '');
const st = useRef({ x: at(62), v: 0, raf: 0 });
// The lamp follows the value on a spring, so the shadows swing round rather than jump.
useEffect(() => {
const q = st.current;
const target = at(v);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
q.x = target;
setLx(target);
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
q.v += (-200 * (q.x - target) - 26 * q.v) * dt;
q.x += q.v * dt;
if (Math.abs(q.x - target) < 0.05 && Math.abs(q.v) < 0.5) {
q.x = target;
q.v = 0;
setLx(target);
return;
}
setLx(q.x);
q.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(q.raf);
q.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(q.raf);
}, [v]);
return (
<>
<style>
{css}
</style>
<div className="slider-cast">
<svg className="slider-cast__svg" aria-hidden="true">
<defs>
<radialGradient id={`${id}g`}>
<stop className="slider-cast__glow" offset="0" stopOpacity={0.3} />
<stop className="slider-cast__glow" offset="1" stopOpacity={0} />
</radialGradient>
<clipPath id={`${id}k`}>
<rect x={0} y={G - 3} width={W} height={11} rx={5.5} />
</clipPath>
</defs>
<rect className="slider-cast__ground" x={0} y={G - 3} width={W} height={11} rx={5.5} />
<rect className="slider-cast__lit" x={0} y={G - 3} width={lx} height={11} rx={5.5} />
<ellipse className="slider-cast__pool" cx={lx} cy={G + 2} rx={40} ry={7} fill={`url(#${id}g)`} />
{/* Each tick throws its shadow away from the lamp: longer the further off it stands. */}
<g clipPath={`url(#${id}k)`}>
{TICKS.map((tx, i) => {
const d = tx - lx;
const L = Math.min(48, Math.abs(d) * (i % 5 === 0 ? 0.42 : 0.3));
return (
<line
key={tx}
className="slider-cast__shadow"
x1={tx}
y1={G}
x2={tx + Math.sign(d) * L}
y2={G + Math.min(5, L * 0.25)}
opacity={Math.max(0.15, 1 - Math.abs(d) / 220)}
/>
);
})}
</g>
{TICKS.map((tx, i) => (
<line key={tx} className="slider-cast__tick" x1={tx} y1={G} x2={tx} y2={G - (i % 5 === 0 ? 12 : 8)} opacity={0.45 + 0.55 * Math.max(0, 1 - Math.abs(tx - lx) / 110)} />
))}
<circle className="slider-cast__aura" cx={lx} cy={G - 21} r={26} fill={`url(#${id}g)`} />
<line className="slider-cast__post" x1={lx} y1={G + 1} x2={lx} y2={G - 16} />
<circle className="slider-cast__bulb" cx={lx} cy={G - 21} r={5} />
<circle className="slider-cast__ring" cx={lx} cy={G - 21} r={9} />
</svg>
<p className="slider-cast__value" aria-hidden="true">
<span>Light</span>
<b>{v}%</b>
</p>
<input className="slider-cast__input" type="range" min={0} max={100} value={v} onChange={(e) => setV(+e.target.value)} aria-label="Light" aria-valuetext={`${v}%`} />
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderCast(el) on the mounted element instead.
<div class="slider-cast">
<svg class="slider-cast__svg" aria-hidden="true">
<defs>
<radialGradient id="slider-cast-glow">
<stop class="slider-cast__glow" offset="0" stop-opacity="0.3"></stop>
<stop class="slider-cast__glow" offset="1" stop-opacity="0"></stop>
</radialGradient>
<clipPath id="slider-cast-ground">
<rect x="0" y="39" width="240" height="11" rx="5.5"></rect>
</clipPath>
</defs>
<rect class="slider-cast__ground" x="0" y="39" width="240" height="11" rx="5.5"></rect>
<rect class="slider-cast__lit" x="0" y="39" width="146.88" height="11" rx="5.5"></rect>
<ellipse class="slider-cast__pool" cx="146.88" cy="44" rx="40" ry="7" fill="url(#slider-cast-glow)"></ellipse>
<g clip-path="url(#slider-cast-ground)">
<line class="slider-cast__shadow" x1="10" y1="42" x2="-38" y2="47" opacity="0.378"></line>
<line class="slider-cast__shadow" x1="21" y1="42" x2="-16.764" y2="47" opacity="0.428"></line>
<line class="slider-cast__shadow" x1="32" y1="42" x2="-2.464" y2="47" opacity="0.478"></line>
<line class="slider-cast__shadow" x1="43" y1="42" x2="11.836" y2="47" opacity="0.528"></line>
<line class="slider-cast__shadow" x1="54" y1="42" x2="26.136" y2="47" opacity="0.578"></line>
<line class="slider-cast__shadow" x1="65" y1="42" x2="30.61" y2="47" opacity="0.628"></line>
<line class="slider-cast__shadow" x1="76" y1="42" x2="54.736" y2="47" opacity="0.678"></line>
<line class="slider-cast__shadow" x1="87" y1="42" x2="69.036" y2="46.491" opacity="0.728"></line>
<line class="slider-cast__shadow" x1="98" y1="42" x2="83.336" y2="45.666" opacity="0.778"></line>
<line class="slider-cast__shadow" x1="109" y1="42" x2="97.636" y2="44.841" opacity="0.828"></line>
<line class="slider-cast__shadow" x1="120" y1="42" x2="108.71" y2="44.822" opacity="0.878"></line>
<line class="slider-cast__shadow" x1="131" y1="42" x2="126.236" y2="43.191" opacity="0.928"></line>
<line class="slider-cast__shadow" x1="142" y1="42" x2="140.536" y2="42.366" opacity="0.978"></line>
<line class="slider-cast__shadow" x1="153" y1="42" x2="154.836" y2="42.459" opacity="0.972"></line>
<line class="slider-cast__shadow" x1="164" y1="42" x2="169.136" y2="43.284" opacity="0.922"></line>
<line class="slider-cast__shadow" x1="175" y1="42" x2="186.81" y2="44.953" opacity="0.872"></line>
<line class="slider-cast__shadow" x1="186" y1="42" x2="197.736" y2="44.934" opacity="0.822"></line>
<line class="slider-cast__shadow" x1="197" y1="42" x2="212.036" y2="45.759" opacity="0.772"></line>
<line class="slider-cast__shadow" x1="208" y1="42" x2="226.336" y2="46.584" opacity="0.722"></line>
<line class="slider-cast__shadow" x1="219" y1="42" x2="240.636" y2="47" opacity="0.672"></line>
<line class="slider-cast__shadow" x1="230" y1="42" x2="264.91" y2="47" opacity="0.622"></line>
</g>
<line class="slider-cast__tick" x1="10" y1="42" x2="10" y2="30" opacity="0.45"></line>
<line class="slider-cast__tick" x1="21" y1="42" x2="21" y2="34" opacity="0.45"></line>
<line class="slider-cast__tick" x1="32" y1="42" x2="32" y2="34" opacity="0.45"></line>
<line class="slider-cast__tick" x1="43" y1="42" x2="43" y2="34" opacity="0.481"></line>
<line class="slider-cast__tick" x1="54" y1="42" x2="54" y2="34" opacity="0.536"></line>
<line class="slider-cast__tick" x1="65" y1="42" x2="65" y2="30" opacity="0.591"></line>
<line class="slider-cast__tick" x1="76" y1="42" x2="76" y2="34" opacity="0.646"></line>
<line class="slider-cast__tick" x1="87" y1="42" x2="87" y2="34" opacity="0.701"></line>
<line class="slider-cast__tick" x1="98" y1="42" x2="98" y2="34" opacity="0.756"></line>
<line class="slider-cast__tick" x1="109" y1="42" x2="109" y2="34" opacity="0.811"></line>
<line class="slider-cast__tick" x1="120" y1="42" x2="120" y2="30" opacity="0.866"></line>
<line class="slider-cast__tick" x1="131" y1="42" x2="131" y2="34" opacity="0.921"></line>
<line class="slider-cast__tick" x1="142" y1="42" x2="142" y2="34" opacity="0.976"></line>
<line class="slider-cast__tick" x1="153" y1="42" x2="153" y2="34" opacity="0.969"></line>
<line class="slider-cast__tick" x1="164" y1="42" x2="164" y2="34" opacity="0.914"></line>
<line class="slider-cast__tick" x1="175" y1="42" x2="175" y2="30" opacity="0.859"></line>
<line class="slider-cast__tick" x1="186" y1="42" x2="186" y2="34" opacity="0.804"></line>
<line class="slider-cast__tick" x1="197" y1="42" x2="197" y2="34" opacity="0.749"></line>
<line class="slider-cast__tick" x1="208" y1="42" x2="208" y2="34" opacity="0.694"></line>
<line class="slider-cast__tick" x1="219" y1="42" x2="219" y2="34" opacity="0.639"></line>
<line class="slider-cast__tick" x1="230" y1="42" x2="230" y2="30" opacity="0.584"></line>
<circle class="slider-cast__aura" cx="146.88" cy="21" r="26" fill="url(#slider-cast-glow)"></circle>
<line class="slider-cast__post" x1="146.88" y1="43" x2="146.88" y2="26"></line>
<circle class="slider-cast__bulb" cx="146.88" cy="21" r="5"></circle>
<circle class="slider-cast__ring" cx="146.88" cy="21" r="9"></circle>
</svg>
<p class="slider-cast__value" aria-hidden="true">
<span>Light</span>
<b>62%</b>
</p>
<input class="slider-cast__input" type="range" min="0" max="100" aria-label="Light" aria-valuetext="62%" value="62"/>
</div>.slider-cast { position: relative; width: 240px; height: 76px; --slider-cast-shadow: light-dark(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.8)); }
.slider-cast__svg { position: absolute; left: 0; top: 0; width: 240px; height: 56px; overflow: visible; }
.slider-cast__ground { fill: light-dark(#ebedf0, #232529); }
.slider-cast__lit { fill: light-dark(#cbced3, #393c41); }
.slider-cast__shadow { stroke: var(--slider-cast-shadow); stroke-width: 2.4; stroke-linecap: round; }
.slider-cast__tick { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.6; stroke-linecap: round; }
.slider-cast__post { stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2; stroke-linecap: round; }
.slider-cast__bulb { fill: light-dark(#373a40, #f1f2f3); }
.slider-cast__glow { stop-color: light-dark(#373a40, #f1f2f3); }
.slider-cast__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-cast:has(.slider-cast__input:focus-visible) .slider-cast__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-cast__value { position: absolute; left: 0; right: 0; top: 58px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-cast__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-cast__input { position: absolute; left: 0; top: 0; width: 100%; height: 56px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-cast__input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 56px; }
.slider-cast__input::-moz-range-thumb { width: 16px; height: 56px; border: 0; }function initSliderCast(root) {
var W = 240;
var G = 42;
var input = root.querySelector('.slider-cast__input');
var lit = root.querySelector('.slider-cast__lit');
var pool = root.querySelector('.slider-cast__pool');
var aura = root.querySelector('.slider-cast__aura');
var post = root.querySelector('.slider-cast__post');
var bulb = root.querySelector('.slider-cast__bulb');
var ring = root.querySelector('.slider-cast__ring');
var shadows = root.querySelectorAll('.slider-cast__shadow');
var ticks = root.querySelectorAll('.slider-cast__tick');
var label = root.querySelector('.slider-cast__value b');
var x = at(+input.value);
var v = 0;
var raf = 0;
function at(n) { return 8 + (n / 100) * (W - 16); }
// Each tick throws its shadow away from the lamp: longer the further off it stands.
function draw() {
lit.setAttribute('width', x);
[pool, aura, bulb, ring].forEach(function (c) { c.setAttribute('cx', x); });
post.setAttribute('x1', x);
post.setAttribute('x2', x);
ticks.forEach(function (t, i) {
var tx = 10 + i * 11;
var d = tx - x;
var L = Math.min(48, Math.abs(d) * (i % 5 === 0 ? 0.42 : 0.3));
shadows[i].setAttribute('x2', tx + Math.sign(d) * L);
shadows[i].setAttribute('y2', G + Math.min(5, L * 0.25));
shadows[i].setAttribute('opacity', Math.max(0.15, 1 - Math.abs(d) / 220));
t.setAttribute('opacity', 0.45 + 0.55 * Math.max(0, 1 - Math.abs(d) / 110));
});
}
// The lamp follows the value on a spring, so the shadows swing round rather than jump.
function run() {
cancelAnimationFrame(raf);
var goal = at(+input.value);
label.textContent = input.value + '%';
input.setAttribute('aria-valuetext', input.value + '%');
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
x = goal;
return draw();
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
v += (-200 * (x - goal) - 26 * v) * dt;
x += v * dt;
if (Math.abs(x - goal) < 0.05 && Math.abs(v) < 0.5) {
x = goal;
v = 0;
return draw();
}
draw();
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
input.addEventListener('input', run);
return function () {
cancelAnimationFrame(raf);
input.removeEventListener('input', run);
};
}
document.querySelectorAll('.slider-cast').forEach(initSliderCast);Goal: Create a "Cast" slider.
Visual behavior: A light slider whose thumb is a small lamp: the scale ticks stand up from the track and each throws a shadow away from it, long far off and short close by, swinging round as the lamp passes.
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.
- Each tick has a shadow line on the ground band running away from the lamp, its length a fixed share of the tick's distance from the lamp (taller ticks cast further) and its opacity fading with distance. The shadows are clipped to the band.
- The drawn position follows the input on a damped spring in requestAnimationFrame, so a click far along the track or a Page Up travels there rather than jumping. A soft radial glow sits round the bulb and a pool of light on the band under it.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the bulb. Under prefers-reduced-motion the lamp moves without the spring.
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 "Cast" slider.
Visual behavior: A light slider whose thumb is a small lamp: the scale ticks stand up from the track and each throws a shadow away from it, long far off and short close by, swinging round as the lamp passes.
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.
- Each tick has a shadow line on the ground band running away from the lamp, its length a fixed share of the tick's distance from the lamp (taller ticks cast further) and its opacity fading with distance. The shadows are clipped to the band.
- The drawn position follows the input on a damped spring in requestAnimationFrame, so a click far along the track or a Page Up travels there rather than jumping. A soft radial glow sits round the bulb and a pool of light on the band under it.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the bulb. Under prefers-reduced-motion the lamp moves without the spring.
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 light slider whose thumb is a small lamp: the scale ticks stand up from the track and each throws a shadow away from it, long far off and short close by, swinging round as the lamp passes.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, continuous, spring
- Techniques
- svg, spring, raf, radial 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 percent; keyboard focus rings the bulb. Under prefers-reduced-motion the lamp moves without the spring.