Vial
import { useEffect, useRef, useState } from 'react';
const W = 240;
const MID = 16;
const RX = 17;
const RY = 8.5;
const pos = (v: number) => RX + 4 + ((v + 50) / 100) * (W - 2 * RX - 8);
const css = `
.slider-vial { position: relative; width: ${W}px; height: 58px; }
.slider-vial__svg { display: block; width: ${W}px; height: 32px; overflow: visible; }
.slider-vial__tube { stroke: light-dark(#cbced3, #393c41); stroke-width: 1; }
.slider-vial__top { stop-color: light-dark(#ebedf0, #232529); }
.slider-vial__bottom { stop-color: light-dark(#dbdde1, #2e3035); }
.slider-vial__glint { stroke: light-dark(#fbfcfd, #7d8087); stroke-width: 1.5; stroke-linecap: round; opacity: 0.5; }
.slider-vial__mark { stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1; }
.slider-vial__tick { stroke: light-dark(#cbced3, #393c41); stroke-width: 1; }
.slider-vial__bubble { fill: light-dark(light-dark(#fbfcfd, #121417), color-mix(in oklab, light-dark(#5f626a, #a7abb2) 55%, light-dark(#dbdde1, #2e3035))); stroke: light-dark(#5f626a, #a7abb2); stroke-width: 0.8; }
.slider-vial__shine { fill: light-dark(#fbfcfd, #c6c8cd); opacity: 0.75; }
.slider-vial__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-vial:has(.slider-vial__input:focus-visible) .slider-vial__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-vial__scale { position: absolute; top: 40px; left: 0; right: 0; display: flex; justify-content: space-between; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-vial__value { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-vial__input { position: absolute; left: 0; top: 0; width: 100%; height: 32px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-vial__input::-webkit-slider-thumb { -webkit-appearance: none; width: ${2 * RX + 8}px; height: 32px; }
.slider-vial__input::-moz-range-thumb { width: ${2 * RX + 8}px; height: 32px; border: 0; }
`;
/**
* The bubble trails the value on one spring, and its stretch rides a second, looser one,
* so it lengthens while it travels and wobbles round and long again as it stops.
*/
function useBubble(target: number) {
const [s, setS] = useState({ x: target, stretch: 0 });
const st = useRef({ x: target, v: 0, s: 0, sv: 0, raf: 0 });
useEffect(() => {
const q = st.current;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
Object.assign(q, { x: target, v: 0, s: 0, sv: 0 });
setS({ x: target, stretch: 0 });
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
q.v += (-180 * (q.x - target) - 22 * q.v) * dt;
q.x += q.v * dt;
q.sv += (-520 * (q.s - Math.min(12, Math.abs(q.v) * 0.035)) - 9 * q.sv) * dt;
q.s += q.sv * dt;
if (Math.abs(q.x - target) < 0.05 && Math.abs(q.v) < 1 && Math.abs(q.s) < 0.05 && Math.abs(q.sv) < 0.5) {
Object.assign(q, { x: target, v: 0, s: 0, sv: 0 });
setS({ x: target, stretch: 0 });
return;
}
setS({ x: q.x, stretch: q.s });
q.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(q.raf);
q.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(q.raf);
}, [target]);
return s;
}
export function VialSlider() {
const [v, setV] = useState(-18);
const dragging = useRef(false);
const { x, stretch } = useBubble(pos(v));
const rx = RX + stretch;
const ry = Math.max(5.5, RY - stretch * 0.36);
const C = pos(0);
return (
<>
<style>
{css}
</style>
<div className="slider-vial">
<svg className="slider-vial__svg" aria-hidden="true">
<defs>
<linearGradient id="slider-vial-liquid" x1="0" y1="0" x2="0" y2="1">
<stop className="slider-vial__top" offset="0" />
<stop className="slider-vial__bottom" offset="1" />
</linearGradient>
</defs>
<rect className="slider-vial__tube" x={0.5} y={MID - 12} width={W - 1} height={24} rx={12} fill="url(#slider-vial-liquid)" />
{[-0.5, -0.25, 0.25, 0.5].map((f) => (
<line key={f} className="slider-vial__tick" x1={C + f * (W - 2 * RX - 8)} x2={C + f * (W - 2 * RX - 8)} y1={MID - 4} y2={MID + 4} />
))}
<ellipse className="slider-vial__bubble" cx={x} cy={MID} rx={rx} ry={ry} />
<ellipse className="slider-vial__shine" cx={x - rx * 0.12} cy={MID - ry * 0.5} rx={rx * 0.55} ry={1.4} />
{/* The two centre lines sit over the bubble, etched into the glass. */}
<line className="slider-vial__mark" x1={C - RX - 3} x2={C - RX - 3} y1={MID - 12} y2={MID + 12} />
<line className="slider-vial__mark" x1={C + RX + 3} x2={C + RX + 3} y1={MID - 12} y2={MID + 12} />
<line className="slider-vial__glint" x1={14} x2={W - 14} y1={MID - 8} y2={MID - 8} />
<rect className="slider-vial__ring" x={x - rx - 5} y={MID - 15} width={2 * rx + 10} height={30} rx={15} />
</svg>
<div className="slider-vial__scale" aria-hidden="true">
<span>L</span>
<span className="slider-vial__value">{v === 0 ? 'Centre' : `${v < 0 ? 'L' : 'R'} ${Math.abs(v)}`}</span>
<span>R</span>
</div>
<input
className="slider-vial__input"
type="range"
min={-50}
max={50}
value={v}
onPointerDown={() => (dragging.current = true)}
onPointerUp={() => (dragging.current = false)}
onChange={(e) => {
const n = +e.target.value;
// A drag settles into the centre when it comes close; arrow keys still step through it.
setV(dragging.current && Math.abs(n) <= 2 ? 0 : n);
}}
aria-label="Balance"
aria-valuetext={v === 0 ? 'Centre' : `${Math.abs(v)} ${v < 0 ? 'left' : 'right'}`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderVial(el) on the mounted element instead.
<div class="slider-vial">
<svg class="slider-vial__svg" aria-hidden="true">
<defs>
<linearGradient id="slider-vial-liquid" x1="0" y1="0" x2="0" y2="1">
<stop class="slider-vial__top" offset="0"></stop>
<stop class="slider-vial__bottom" offset="1"></stop>
</linearGradient>
</defs>
<rect class="slider-vial__tube" x="0.5" y="4" width="239" height="24" rx="12" fill="url(#slider-vial-liquid)"></rect>
<line class="slider-vial__tick" x1="21" x2="21" y1="12" y2="20"></line>
<line class="slider-vial__tick" x1="70.5" x2="70.5" y1="12" y2="20"></line>
<line class="slider-vial__tick" x1="169.5" x2="169.5" y1="12" y2="20"></line>
<line class="slider-vial__tick" x1="219" x2="219" y1="12" y2="20"></line>
<ellipse class="slider-vial__bubble" cx="84.36" cy="16" rx="17" ry="8.5"></ellipse>
<ellipse class="slider-vial__shine" cx="82.32" cy="11.75" rx="9.35" ry="1.4"></ellipse>
<line class="slider-vial__mark" x1="100" x2="100" y1="4" y2="28"></line>
<line class="slider-vial__mark" x1="140" x2="140" y1="4" y2="28"></line>
<line class="slider-vial__glint" x1="14" x2="226" y1="8" y2="8"></line>
<rect class="slider-vial__ring" x="62.36" y="1" width="44" height="30" rx="15"></rect>
</svg>
<div class="slider-vial__scale" aria-hidden="true">
<span>L</span>
<span class="slider-vial__value">L 18</span>
<span>R</span>
</div>
<input class="slider-vial__input" type="range" min="-50" max="50" aria-label="Balance" aria-valuetext="18 left" value="-18"/>
</div>.slider-vial { position: relative; width: 240px; height: 58px; }
.slider-vial__svg { display: block; width: 240px; height: 32px; overflow: visible; }
.slider-vial__tube { stroke: light-dark(#cbced3, #393c41); stroke-width: 1; }
.slider-vial__top { stop-color: light-dark(#ebedf0, #232529); }
.slider-vial__bottom { stop-color: light-dark(#dbdde1, #2e3035); }
.slider-vial__glint { stroke: light-dark(#fbfcfd, #7d8087); stroke-width: 1.5; stroke-linecap: round; opacity: 0.5; }
.slider-vial__mark { stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1; }
.slider-vial__tick { stroke: light-dark(#cbced3, #393c41); stroke-width: 1; }
.slider-vial__bubble { fill: light-dark(light-dark(#fbfcfd, #121417), color-mix(in oklab, light-dark(#5f626a, #a7abb2) 55%, light-dark(#dbdde1, #2e3035))); stroke: light-dark(#5f626a, #a7abb2); stroke-width: 0.8; }
.slider-vial__shine { fill: light-dark(#fbfcfd, #c6c8cd); opacity: 0.75; }
.slider-vial__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-vial:has(.slider-vial__input:focus-visible) .slider-vial__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-vial__scale { position: absolute; top: 40px; left: 0; right: 0; display: flex; justify-content: space-between; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-vial__value { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-vial__input { position: absolute; left: 0; top: 0; width: 100%; height: 32px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-vial__input::-webkit-slider-thumb { -webkit-appearance: none; width: 42px; height: 32px; }
.slider-vial__input::-moz-range-thumb { width: 42px; height: 32px; border: 0; }function initSliderVial(root) {
var W = 240;
var MID = 16;
var RX = 17;
var RY = 8.5;
var input = root.querySelector('.slider-vial__input');
var bubble = root.querySelector('.slider-vial__bubble');
var shine = root.querySelector('.slider-vial__shine');
var ring = root.querySelector('.slider-vial__ring');
var label = root.querySelector('.slider-vial__value');
var dragging = false;
// The bubble trails the value on one spring; its stretch rides a second, looser one.
var q = { x: pos(+input.value), v: 0, s: 0, sv: 0 };
var raf = 0;
function pos(v) { return RX + 4 + ((v + 50) / 100) * (W - 2 * RX - 8); }
function draw() {
var rx = RX + q.s;
var ry = Math.max(5.5, RY - q.s * 0.36);
bubble.setAttribute('cx', q.x);
bubble.setAttribute('rx', rx);
bubble.setAttribute('ry', ry);
shine.setAttribute('cx', q.x - rx * 0.12);
shine.setAttribute('cy', MID - ry * 0.5);
shine.setAttribute('rx', rx * 0.55);
ring.setAttribute('x', q.x - rx - 5);
ring.setAttribute('width', 2 * rx + 10);
}
function run() {
cancelAnimationFrame(raf);
var goal = pos(+input.value);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
q = { x: goal, v: 0, s: 0, sv: 0 };
return draw();
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
q.v += (-180 * (q.x - goal) - 22 * q.v) * dt;
q.x += q.v * dt;
// It lengthens while it travels and wobbles round and long again as it stops.
q.sv += (-520 * (q.s - Math.min(12, Math.abs(q.v) * 0.035)) - 9 * q.sv) * dt;
q.s += q.sv * dt;
if (Math.abs(q.x - goal) < 0.05 && Math.abs(q.v) < 1 && Math.abs(q.s) < 0.05 && Math.abs(q.sv) < 0.5) {
q = { x: goal, v: 0, s: 0, sv: 0 };
return draw();
}
draw();
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
function onInput() {
// A drag settles into the centre when it comes close; arrow keys still step through it.
if (dragging && Math.abs(+input.value) <= 2) input.value = 0;
var v = +input.value;
label.textContent = v === 0 ? 'Centre' : (v < 0 ? 'L ' : 'R ') + Math.abs(v);
input.setAttribute('aria-valuetext', v === 0 ? 'Centre' : Math.abs(v) + (v < 0 ? ' left' : ' right'));
run();
}
function down() { dragging = true; }
function up() { dragging = false; }
input.addEventListener('input', onInput);
input.addEventListener('pointerdown', down);
input.addEventListener('pointerup', up);
input.addEventListener('pointercancel', up);
return function () {
cancelAnimationFrame(raf);
input.removeEventListener('input', onInput);
input.removeEventListener('pointerdown', down);
input.removeEventListener('pointerup', up);
input.removeEventListener('pointercancel', up);
};
}
document.querySelectorAll('.slider-vial').forEach(initSliderVial);Goal: Create a "Vial" slider.
Visual behavior: A stereo balance control shaped like a spirit level: the thumb is the air bubble, which stretches as it moves, wobbles as it stops and settles between the two centre marks.
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 tube is a rounded SVG rect filled with a vertical gradient, with quarter ticks, two centre lines and a glint. The bubble is an ellipse that follows the value on a spring; its stretch rides a second, looser spring whose target is the speed, so it lengthens in motion and wobbles round and long as it stops, losing height as it gains length.
- During a pointer drag, values within 2 of the centre snap to it, so the bubble settles between the marks; the arrow keys still step through the centre one at a time.
Accessibility: A native range input from -50 to 50 with an aria-label and an aria-valuetext of Centre or a number left or right; keyboard focus rings the bubble. Under prefers-reduced-motion the bubble moves without the springs.
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 "Vial" slider.
Visual behavior: A stereo balance control shaped like a spirit level: the thumb is the air bubble, which stretches as it moves, wobbles as it stops and settles between the two centre marks.
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 tube is a rounded SVG rect filled with a vertical gradient, with quarter ticks, two centre lines and a glint. The bubble is an ellipse that follows the value on a spring; its stretch rides a second, looser spring whose target is the speed, so it lengthens in motion and wobbles round and long as it stops, losing height as it gains length.
- During a pointer drag, values within 2 of the centre snap to it, so the bubble settles between the marks; the arrow keys still step through the centre one at a time.
Accessibility: A native range input from -50 to 50 with an aria-label and an aria-valuetext of Centre or a number left or right; keyboard focus rings the bubble. Under prefers-reduced-motion the bubble moves without the springs.
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 stereo balance control shaped like a spirit level: the thumb is the air bubble, which stretches as it moves, wobbles as it stops and settles between the two centre marks.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- state change, spring, gradient
- Techniques
- svg, spring, raf, gradient
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input from -50 to 50 with an aria-label and an aria-valuetext of Centre or a number left or right; keyboard focus rings the bubble. Under prefers-reduced-motion the bubble moves without the springs.