SloshGauge
Tank gauge whose liquid chases the value with mass, tilts with its own speed and splashes against the top when it slams full; optionally a vertical slider.
Live preview
Loading demo…
Installation
npx shadcn@latest add https://reactbits.dev/r/SloshGauge-TS-TW.json
Source
'use client';
import React, {
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
type PointerEvent
} from 'react';
export interface SloshGaugeProps {
value?: number;
defaultValue?: number;
onChange?: (value: number) => void;
interactive?: boolean;
showValue?: boolean;
disabled?: boolean;
liquidColor?: string;
glassColor?: string;
width?: number;
height?: number;
radius?: number;
ticks?: number;
viscosity?: number;
tilt?: number;
splash?: number;
unit?: string;
ariaLabel?: string;
className?: string;
}
interface Sim {
x: number;
v: number;
L: number;
raf: number;
last: number;
}
interface Grip {
id: number;
rect: DOMRect;
scale: number;
band: boolean;
grab: number | null;
at: number;
sent: number;
}
interface Live {
viscosity: number;
tilt: number;
splash: number;
width: number;
height: number;
value: number | undefined;
onChange: ((value: number) => void) | undefined;
unit: string;
}
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
const H = 1 / 120;
const DT_MAX = 0.05;
const TILT_GAIN = 0.07;
const TILT_MAX = 30;
const REST_POS = 0.05;
const REST_VEL = 2;
const STEP = 2;
const BIG = 10;
const BAND = 10;
const onColor = (hex: string) => {
const raw = hex.replace('#', '');
const full = raw.length === 3 ? [...raw].map(c => c + c).join('') : raw;
const n = parseInt(full, 16);
if (Number.isNaN(n)) return '#ffffff';
return (((n >> 16) & 255) * 299 + ((n >> 8) & 255) * 587 + (n & 255) * 114) / 1000 >= 150 ? '#111111' : '#ffffff';
};
const SloshGauge: React.FC<SloshGaugeProps> = ({
value,
defaultValue = 60,
onChange,
interactive = false,
showValue = true,
disabled = false,
liquidColor = '#f5f5f5',
glassColor = '#27272a',
width = 88,
height = 180,
radius = 20,
ticks = 4,
viscosity = 0.15,
tilt = 0.45,
splash = 0.42,
unit = '%',
ariaLabel = 'Level',
className = ''
}) => {
const root = useRef<HTMLDivElement>(null);
const liquid = useRef<HTMLDivElement>(null);
const marker = useRef<HTMLDivElement>(null);
const textA = useRef<HTMLSpanElement>(null);
const textB = useRef<HTMLSpanElement>(null);
const start = clamp(value ?? defaultValue, 0, 100);
const sim = useRef<Sim>({ x: start, v: 0, L: start, raf: 0, last: 0 });
const grip = useRef<Grip | null>(null);
const reduce = useRef(false);
const [held, setHeld] = useState(false);
const live = useRef<Live>({} as Live);
live.current = { viscosity, tilt, splash, width, height, value, onChange, unit };
const paint = () => {
const { x, v, L } = sim.current;
const { tilt: t, width: W, height: Hh } = live.current;
const th = reduce.current ? 0 : clamp(t * v * TILT_GAIN, -TILT_MAX, TILT_MAX);
const lean = (Math.tan((th * Math.PI) / 180) * W) / 2;
const top = 100 - x;
if (liquid.current) {
liquid.current.style.clipPath = `polygon(0 calc(${top}% + ${lean}px), 100% calc(${top}% - ${lean}px), 100% 100%, 0 100%)`;
}
if (marker.current) marker.current.style.transform = `translateY(${((100 - L) * Hh) / 100}px)`;
};
const say = () => {
const n = Math.round(sim.current.L);
const s = `${n}${live.current.unit}`;
root.current?.setAttribute('aria-valuenow', String(n));
if (textA.current) textA.current.textContent = s;
if (textB.current) textB.current.textContent = s;
};
const tick = (now: number) => {
const s = sim.current;
const { viscosity: vis, splash: give } = live.current;
const dt = s.last ? Math.min((now - s.last) / 1000, DT_MAX) : H;
s.last = now;
if (vis <= 0) {
s.x = s.L;
s.v = 0;
} else {
const k = 1224 - 1044 * vis;
const zeta = reduce.current ? 1 : 0.26 - 0.17 * vis;
const c = 2 * zeta * Math.sqrt(k);
const rest = reduce.current ? 0 : give;
for (let n = Math.ceil(dt / H), h = dt / n; n > 0; n -= 1) {
s.v = s.v * Math.exp(-c * h) + k * (s.L - s.x) * h;
s.x += s.v * h;
if (s.x > 100) {
s.x = 100;
s.v = -s.v * rest;
} else if (s.x < 0) {
s.x = 0;
s.v = -s.v * rest;
}
}
if (!grip.current && Math.abs(s.L - s.x) < REST_POS && Math.abs(s.v) < REST_VEL) {
s.x = s.L;
s.v = 0;
}
}
paint();
const parked = !grip.current && s.x === s.L && s.v === 0;
if (parked) {
s.raf = 0;
s.last = 0;
} else {
s.raf = requestAnimationFrame(tick);
}
};
const wake = () => {
if (!sim.current.raf) sim.current.raf = requestAnimationFrame(tick);
};
const setLevel = (L: number, instant?: boolean) => {
const s = sim.current;
s.L = clamp(L, 0, 100);
if (instant) {
s.x = s.L;
s.v = 0;
}
say();
wake();
};
useLayoutEffect(() => {
if (value === undefined) return;
if (grip.current && Math.round(sim.current.L) === value) return;
setLevel(value, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
useLayoutEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
const sync = () => {
reduce.current = mq.matches;
};
sync();
mq.addEventListener('change', sync);
say();
paint();
const s = sim.current;
return () => {
mq.removeEventListener('change', sync);
cancelAnimationFrame(s.raf);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
paint();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [width, height, tilt, showValue, interactive, disabled]);
const levelAt = (clientY: number, g: Grip) =>
clamp(((g.rect.bottom - clientY) / g.scale / (root.current?.offsetHeight || g.rect.height)) * 100, 0, 100);
const report = () => {
const g = grip.current;
const n = Math.round(sim.current.L);
if (g && n !== g.sent) {
g.sent = n;
live.current.onChange?.(n);
}
};
const down = (e: PointerEvent<HTMLDivElement>) => {
if (!interactive || disabled || grip.current || e.button !== 0) return;
const el = root.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const scale = rect.height / (el.offsetHeight || rect.height) || 1;
const markerY = rect.top + ((100 - sim.current.L) / 100) * rect.height;
const g: Grip = {
id: e.pointerId,
rect,
scale,
band: Math.abs(e.clientY - markerY) <= BAND * scale,
grab: null,
at: sim.current.L,
sent: NaN
};
grip.current = g;
try {
el.setPointerCapture(e.pointerId);
} catch {}
setHeld(true);
if (g.band) {
wake();
} else {
setLevel(levelAt(e.clientY, g));
report();
}
};
const move = (e: PointerEvent<HTMLDivElement>) => {
const g = grip.current;
if (!g || g.id !== e.pointerId) return;
const at = levelAt(e.clientY, g);
if (g.band && g.grab === null) {
g.grab = sim.current.L - at;
return;
}
setLevel(at + (g.grab ?? 0));
report();
};
const up = (e: { pointerId: number }, reason?: 'cancel' | 'escape') => {
const g = grip.current;
if (!g || g.id !== e.pointerId) return;
grip.current = null;
try {
root.current?.releasePointerCapture(e.pointerId);
} catch {}
setHeld(false);
if (reason === 'escape') {
setLevel(g.at);
live.current.onChange?.(Math.round(g.at));
} else if (live.current.value !== undefined && Math.round(sim.current.L) !== live.current.value) {
setLevel(live.current.value);
}
wake();
};
const key = (e: KeyboardEvent<HTMLDivElement>) => {
if (!interactive || disabled) return;
if (e.key === 'Escape') {
if (grip.current) up({ pointerId: grip.current.id }, 'escape');
return;
}
const L = sim.current.L;
const d = e.shiftKey ? BIG : STEP;
const next: number | undefined = (
{
ArrowUp: L + d,
ArrowRight: L + d,
ArrowDown: L - d,
ArrowLeft: L - d,
PageUp: L + BIG,
PageDown: L - BIG,
Home: 0,
End: 100
} as Record<string, number>
)[e.key];
if (next === undefined) return;
e.preventDefault();
setLevel(next, true);
live.current.onChange?.(Math.round(sim.current.L));
};
const r = Math.min(radius, width / 2, height / 2);
const label = `${Math.round(start)}${unit}`;
return (
<div
ref={root}
className={`group relative isolate box-border inline-block overflow-clip leading-none font-semibold tabular-nums outline-none select-none [width:var(--sg-w)] [height:var(--sg-h)] [border-radius:var(--sg-r)] [background:var(--sg-glass)] [font-size:var(--sg-font)] [-webkit-tap-highlight-color:transparent] data-[interactive=true]:cursor-ns-resize data-[interactive=true]:touch-none data-[interactive=true]:[-webkit-touch-callout:none] data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50${className ? ` ${className}` : ''}`}
role={interactive ? 'slider' : 'meter'}
tabIndex={interactive && !disabled ? 0 : undefined}
aria-label={ariaLabel}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(start)}
aria-orientation={interactive ? 'vertical' : undefined}
aria-disabled={disabled || undefined}
data-interactive={interactive ? 'true' : 'false'}
data-held={held ? 'true' : 'false'}
data-disabled={disabled ? 'true' : 'false'}
style={
{
'--sg-w': `${width}px`,
'--sg-h': `${height}px`,
'--sg-r': `${r}px`,
'--sg-glass': glassColor,
'--sg-liquid': liquidColor,
'--sg-on-liquid': onColor(liquidColor),
'--sg-ticks': ticks,
'--sg-font': `${clamp(Math.round(width * 0.16), 12, 20)}px`
} as CSSProperties
}
onPointerDown={down}
onPointerMove={move}
onPointerUp={e => up(e)}
onPointerCancel={e => up(e, 'cancel')}
onLostPointerCapture={e => up(e, 'cancel')}
onKeyDown={key}
>
{showValue ? (
<span
ref={textA}
className="pointer-events-none absolute inset-0 grid place-items-center [color:color-mix(in_srgb,currentColor_65%,transparent)]"
aria-hidden="true"
>
{label}
</span>
) : null}
<div
ref={liquid}
className="absolute inset-0 [background:var(--sg-liquid)] [clip-path:polygon(0_100%,100%_100%,100%_100%,0_100%)]"
aria-hidden="true"
>
{showValue ? (
<span
ref={textB}
className="pointer-events-none absolute inset-0 grid place-items-center [color:var(--sg-on-liquid)]"
>
{label}
</span>
) : null}
</div>
{ticks > 0 ? (
<div
className="pointer-events-none absolute right-0 bottom-0 h-full w-[22%] bg-bottom bg-repeat-y [background-image:linear-gradient(to_top,color-mix(in_srgb,currentColor_22%,transparent)_0_1px,transparent_1px)] [background-size:100%_calc(100%/var(--sg-ticks))]"
aria-hidden="true"
/>
) : null}
{interactive && !disabled ? (
<div
ref={marker}
className="pointer-events-none absolute inset-x-[10%] top-0 -mt-px h-0.5 rounded-[1px] bg-current opacity-0 [transition:opacity_150ms_ease] group-data-[held=true]:opacity-100"
aria-hidden="true"
/>
) : null}
</div>
);
};
export default SloshGauge;