Slosh
import { useEffect, useId, useRef, useState } from 'react';
const W = 240;
const TOP = 18;
const BOT = 40;
const MIDY = (TOP + BOT) / 2;
const at = (v: number) => 12 + (v / 100) * (W - 24);
const css = `
.slider-slosh { position: relative; width: ${W}px; height: 76px; }
.slider-slosh__svg { position: absolute; left: 0; top: 0; width: ${W}px; height: 56px; overflow: visible; }
.slider-slosh__tube { fill: light-dark(#ebedf0, #232529); stroke: light-dark(#cbced3, #393c41); stroke-width: 1.5; }
.slider-slosh__liquid { fill: light-dark(#50535b, #c6c8cd); }
.slider-slosh__shine { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.5; stroke-linecap: round; opacity: 0.35; }
.slider-slosh__mark { fill: light-dark(#373a40, #f1f2f3); }
.slider-slosh__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-slosh:has(.slider-slosh__input:focus-visible) .slider-slosh__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-slosh__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-slosh__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-slosh__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-slosh__input::-webkit-slider-thumb { -webkit-appearance: none; width: 24px; height: 56px; }
.slider-slosh__input::-moz-range-thumb { width: 24px; height: 56px; border: 0; }
`;
export function SloshSlider() {
const [v, setV] = useState(58);
const [s, setS] = useState({ x: at(58), lean: 0 });
const id = useId().replace(/[^a-zA-Z0-9]/g, '');
const st = useRef({ x: at(58), vx: 0, lean: 0, vl: 0, raf: 0 });
// The front follows on a spring. The liquid's face lags behind its speed and swings past
// upright once it stops, on a looser spring of its own: that swing is the slosh.
useEffect(() => {
const q = st.current;
const goal = at(v);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
Object.assign(q, { x: goal, vx: 0, lean: 0, vl: 0 });
setS({ x: goal, lean: 0 });
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
q.vx += (-170 * (q.x - goal) - 22 * q.vx) * dt;
q.x += q.vx * dt;
const want = Math.max(-9, Math.min(9, -q.vx * 0.035));
q.vl += (-260 * (q.lean - want) - 6 * q.vl) * dt;
q.lean += q.vl * dt;
setS({ x: q.x, lean: q.lean });
if (Math.abs(q.x - goal) < 0.05 && Math.abs(q.vx) < 0.5 && Math.abs(q.lean) < 0.05 && Math.abs(q.vl) < 0.5) {
Object.assign(q, { x: goal, vx: 0, lean: 0, vl: 0 });
setS({ x: goal, lean: 0 });
return;
}
q.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(q.raf);
q.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(q.raf);
}, [v]);
const { x, lean } = s;
const liquid = `M 0 ${TOP} L ${x + lean} ${TOP} Q ${x + 3 + Math.abs(lean) * 0.4} ${MIDY} ${x - lean} ${BOT} L 0 ${BOT} Z`;
return (
<>
<style>
{css}
</style>
<div className="slider-slosh">
<svg className="slider-slosh__svg" aria-hidden="true">
<defs>
<clipPath id={`${id}c`}>
<rect x={4} y={TOP} width={W - 8} height={BOT - TOP} rx={(BOT - TOP) / 2} />
</clipPath>
</defs>
<rect className="slider-slosh__tube" x={3.25} y={TOP - 0.75} width={W - 6.5} height={BOT - TOP + 1.5} rx={(BOT - TOP) / 2 + 0.75} />
<g clipPath={`url(#${id}c)`}>
<path className="slider-slosh__liquid" d={liquid} />
<line className="slider-slosh__shine" x1={12} y1={TOP + 4} x2={Math.max(12, x + lean - 5)} y2={TOP + 4} />
</g>
<polygon className="slider-slosh__mark" points={`${x - 5},${TOP - 10} ${x + 5},${TOP - 10} ${x},${TOP - 4}`} />
<polygon className="slider-slosh__mark" points={`${x - 5},${BOT + 10} ${x + 5},${BOT + 10} ${x},${BOT + 4}`} />
<rect className="slider-slosh__ring" x={x - 10} y={TOP - 14} width={20} height={BOT - TOP + 28} rx={7} />
</svg>
<p className="slider-slosh__value" aria-hidden="true">
<span>Fill</span>
<b>{v}%</b>
</p>
<input className="slider-slosh__input" type="range" min={0} max={100} value={v} onChange={(e) => setV(+e.target.value)} aria-label="Fill" aria-valuetext={`${v}%`} />
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderSlosh(el) on the mounted element instead.
<div class="slider-slosh">
<svg class="slider-slosh__svg" aria-hidden="true">
<defs>
<clipPath id="slider-slosh-clip">
<rect x="4" y="18" width="232" height="22" rx="11"></rect>
</clipPath>
</defs>
<rect class="slider-slosh__tube" x="3.25" y="17.25" width="233.5" height="23.5" rx="11.75"></rect>
<g clip-path="url(#slider-slosh-clip)">
<path class="slider-slosh__liquid" d="M 0 18 L 137.28 18 Q 140.28 29 137.28 40 L 0 40 Z"></path>
<line class="slider-slosh__shine" x1="12" y1="22" x2="132.28" y2="22"></line>
</g>
<polygon class="slider-slosh__mark" points="132.28,8 142.28,8 137.28,14"></polygon>
<polygon class="slider-slosh__mark" points="132.28,50 142.28,50 137.28,44"></polygon>
<rect class="slider-slosh__ring" x="127.28" y="4" width="20" height="50" rx="7"></rect>
</svg>
<p class="slider-slosh__value" aria-hidden="true">
<span>Fill</span>
<b>58%</b>
</p>
<input class="slider-slosh__input" type="range" min="0" max="100" aria-label="Fill" aria-valuetext="58%" value="58"/>
</div>.slider-slosh { position: relative; width: 240px; height: 76px; }
.slider-slosh__svg { position: absolute; left: 0; top: 0; width: 240px; height: 56px; overflow: visible; }
.slider-slosh__tube { fill: light-dark(#ebedf0, #232529); stroke: light-dark(#cbced3, #393c41); stroke-width: 1.5; }
.slider-slosh__liquid { fill: light-dark(#50535b, #c6c8cd); }
.slider-slosh__shine { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.5; stroke-linecap: round; opacity: 0.35; }
.slider-slosh__mark { fill: light-dark(#373a40, #f1f2f3); }
.slider-slosh__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-slosh:has(.slider-slosh__input:focus-visible) .slider-slosh__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-slosh__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-slosh__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-slosh__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-slosh__input::-webkit-slider-thumb { -webkit-appearance: none; width: 24px; height: 56px; }
.slider-slosh__input::-moz-range-thumb { width: 24px; height: 56px; border: 0; }function initSliderSlosh(root) {
var W = 240;
var TOP = 18;
var BOT = 40;
var MIDY = (TOP + BOT) / 2;
var input = root.querySelector('.slider-slosh__input');
var liquid = root.querySelector('.slider-slosh__liquid');
var shine = root.querySelector('.slider-slosh__shine');
var marks = root.querySelectorAll('.slider-slosh__mark');
var ring = root.querySelector('.slider-slosh__ring');
var label = root.querySelector('.slider-slosh__value b');
var x = at(+input.value);
var vx = 0;
var lean = 0;
var vl = 0;
var raf = 0;
function at(v) { return 12 + (v / 100) * (W - 24); }
function draw() {
liquid.setAttribute('d', 'M 0 ' + TOP + ' L ' + (x + lean) + ' ' + TOP + ' Q ' + (x + 3 + Math.abs(lean) * 0.4) + ' ' + MIDY + ' ' + (x - lean) + ' ' + BOT + ' L 0 ' + BOT + ' Z');
shine.setAttribute('x2', Math.max(12, x + lean - 5));
marks[0].setAttribute('points', x - 5 + ',' + (TOP - 10) + ' ' + (x + 5) + ',' + (TOP - 10) + ' ' + x + ',' + (TOP - 4));
marks[1].setAttribute('points', x - 5 + ',' + (BOT + 10) + ' ' + (x + 5) + ',' + (BOT + 10) + ' ' + x + ',' + (BOT + 4));
ring.setAttribute('x', x - 10);
}
// The front follows on a spring. The liquid's face lags behind its speed and swings past
// upright once it stops, on a looser spring of its own: that swing is the slosh.
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;
lean = 0;
return draw();
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
vx += (-170 * (x - goal) - 22 * vx) * dt;
x += vx * dt;
var want = Math.max(-9, Math.min(9, -vx * 0.035));
vl += (-260 * (lean - want) - 6 * vl) * dt;
lean += vl * dt;
if (Math.abs(x - goal) < 0.05 && Math.abs(vx) < 0.5 && Math.abs(lean) < 0.05 && Math.abs(vl) < 0.5) {
x = goal;
vx = lean = vl = 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-slosh').forEach(initSliderSlosh);Goal: Create a "Slosh" slider.
Visual behavior: The fill is liquid in a sealed glass tube: drag and its face leans back against the motion, then sloshes forward past upright when you stop and settles level.
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 liquid is one path clipped to the tube: flat from the left end, with a front face that runs from the top at x plus the lean to the bottom at x minus the lean, bulging a little forward.
- The front rides a spring towards the value. The lean chases a target proportional to minus the front's speed on a second, much less damped spring, so it trails while moving and swings past upright once the front stops. A highlight runs along the top.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the markers. Under prefers-reduced-motion the face stays upright and 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 "Slosh" slider.
Visual behavior: The fill is liquid in a sealed glass tube: drag and its face leans back against the motion, then sloshes forward past upright when you stop and settles level.
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 liquid is one path clipped to the tube: flat from the left end, with a front face that runs from the top at x plus the lean to the bottom at x minus the lean, bulging a little forward.
- The front rides a spring towards the value. The lean chases a target proportional to minus the front's speed on a second, much less damped spring, so it trails while moving and swings past upright once the front stops. A highlight runs along the top.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the markers. Under prefers-reduced-motion the face stays upright and 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
The fill is liquid in a sealed glass tube: drag and its face leans back against the motion, then sloshes forward past upright when you stop and settles level.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, spring, fill
- Techniques
- svg, spring, raf
- 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 markers. Under prefers-reduced-motion the face stays upright and moves without the springs.