Liquid Gauge
A usage meter filled with liquid that sloshes, ripples and bubbles, built with React and a wave simulation.
1 exampleFrom KokonutUI (MIT)
Installation
Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:
npx shadcn@latest add https://kryptonite-ui.vercel.app/r/liquid-gauge.jsonInstall manually
Copy this into
components/kokonut/liquid-gauge.tsx:liquid-gauge.tsx"use client"; /** * @author: @dorianbaffier * @description: Liquid Gauge * @version: 1.0.0 * @date: 2026-10-14 * @license: MIT * @website: https://kokonutui.com * @github: https://github.com/kokonut-labs/kokonutui */ import { type KeyboardEvent, type PointerEvent, useCallback, useEffect, useRef, useState, } from "react"; import { cn } from "@/lib/utils"; // The surface is a row of columns joined by springs. Each column bobs // around the fill line and pulls on its neighbours, so a push travels. const COLUMNS = 28; // Simulation, in px and seconds, on a fixed step. const STEP = 1 / 120; const SPREAD = 2600; const RESTORE = 18; const SURFACE_DAMPING = 2.2; // The fill line follows the value on an underdamped spring, so a big change // overshoots a little and settles, like a poured glass. const LEVEL_STIFFNESS = 90; const LEVEL_DAMPING = 11; // How hard a sideways drag rocks the liquid, and how hard the pointer // stirs it when moving through it. const SLOSH = 4.5; const STIR = 20; const MAX_WAVE = 26; // The meniscus: a lighter band riding just above the surface. const RIM = 3; // Bubbles rise while the level is climbing. const BUBBLES = 10; const BUBBLE_RATE = 0.06; const REST_EPSILON = 0.02; export interface LiquidGaugeProps { /** Controlled fill, 0 to 100. */ value?: number; /** Initial fill when uncontrolled. */ defaultValue?: number; onValueChange?: (value: number) => void; /** Turns the gauge into a read-only display. */ readOnly?: boolean; label?: string; /** Capacity shown in the subline, in `unit`. */ total?: number; unit?: string; /** From this fill on, the liquid turns red. */ warnAt?: number; className?: string; } interface Bubble { x: number; y: number; r: number; speed: number; phase: number; alive: boolean; } const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v)); function haptic() { if (typeof navigator !== "undefined" && "vibrate" in navigator) { navigator.vibrate(6); } } function surfacePolygon( heights: Float64Array, levelY: number, width: number, height: number, lift: number ) { let points = ""; for (let i = 0; i < COLUMNS; i++) { const x = (i / (COLUMNS - 1)) * width; const y = clamp(levelY + heights[i] - lift, -40, height + 40); points += `${x.toFixed(1)}px ${y.toFixed(1)}px,`; } return `polygon(${points}${width}px ${height + 40}px,0px ${height + 40}px)`; } function flatPolygon(value: number, lift = 0) { const top = 100 - value; return `polygon(0% calc(${top}% - ${lift}px),100% calc(${top}% - ${lift}px),100% 100%,0% 100%)`; } function drawBubbles( bubbles: Bubble[], nodes: (HTMLSpanElement | null)[], time: number ) { bubbles.forEach((bubble, i) => { const node = nodes[i]; if (!node) { return; } if (!bubble.alive) { node.style.opacity = "0"; return; } const sway = Math.sin(time * 7 + bubble.phase) * 2.5; node.style.opacity = "1"; node.style.transform = `translate3d(${(bubble.x + sway - bubble.r).toFixed(1)}px,${(bubble.y - bubble.r).toFixed(1)}px,0) scale(${(bubble.r / 4).toFixed(3)})`; }); } function setText(nodes: (HTMLSpanElement | null)[], text: string) { for (const node of nodes) { if (node) { node.textContent = text; } } } function GaugeText({ label, status, value, used, total, unit, percentRef, usedRef, }: { label: string; status: string; value: number; used: string; total: number; unit: string; percentRef: (node: HTMLSpanElement | null) => void; usedRef: (node: HTMLSpanElement | null) => void; }) { return ( <> <div className="absolute inset-x-0 top-0 p-6"> <p className="font-medium text-sm">{label}</p> <p className="mt-3 font-semibold text-6xl tabular-nums tracking-tight"> <span ref={percentRef}>{Math.round(value)}</span> <span className="ml-0.5 font-medium text-3xl opacity-60">%</span> </p> <p className="mt-1 text-sm tabular-nums opacity-60"> <span ref={usedRef}>{used}</span> of {total} {unit} used </p> </div> <p className="absolute bottom-6 left-6 font-medium text-sm">{status}</p> {/* Every ten percent, a short tick on the right wall. */} <div className="absolute inset-y-0 right-0 w-4"> {[10, 20, 30, 40, 50, 60, 70, 80, 90].map((tick) => ( <span className={cn( "absolute right-0 h-px bg-current opacity-30", tick === 50 ? "w-3" : "w-1.5" )} key={tick} style={{ top: `${100 - tick}%` }} /> ))} </div> </> ); } export default function LiquidGauge({ value: valueProp, defaultValue = 72, onValueChange, readOnly = false, label = "Storage", total = 128, unit = "GB", warnAt = 90, className, }: LiquidGaugeProps) { const [internal, setInternal] = useState(defaultValue); const value = clamp(valueProp ?? internal, 0, 100); const [dragging, setDragging] = useState(false); // The level, its readout and the surface are written by the simulation; // React renders them once, from the first value, and leaves them alone. const [initial] = useState(value); const rootRef = useRef<HTMLDivElement>(null); const liquidRef = useRef<HTMLDivElement>(null); const rimRef = useRef<HTMLDivElement>(null); const bubbleRefs = useRef<(HTMLSpanElement | null)[]>([]); const percentRefs = useRef<(HTMLSpanElement | null)[]>([]); const usedRefs = useRef<(HTMLSpanElement | null)[]>([]); const sim = useRef({ heights: new Float64Array(COLUMNS), speeds: new Float64Array(COLUMNS), level: value, levelSpeed: 0, target: value, width: 0, height: 0, bubbles: Array.from( { length: BUBBLES }, (): Bubble => ({ x: 0, y: 0, r: 0, speed: 0, phase: 0, alive: false }) ), spawn: 0, shown: Math.round(value), raf: 0, last: 0, carry: 0, time: 0, pointerX: -1, pointerId: -1, visible: true, reduceMotion: false, }); // Read inside the animation loop, which outlives the render it came from. const totalRef = useRef(total); const setValue = useCallback( (next: number) => { const rounded = Math.round(clamp(next, 0, 100)); if (valueProp === undefined) { setInternal(rounded); } onValueChange?.(rounded); }, [valueProp, onValueChange] ); const draw = useCallback(() => { const s = sim.current; const { width, height } = s; if (!(width && height)) { return; } const levelY = height * (1 - s.level / 100); if (liquidRef.current) { liquidRef.current.style.clipPath = surfacePolygon( s.heights, levelY, width, height, 0 ); } if (rimRef.current) { rimRef.current.style.clipPath = surfacePolygon( s.heights, levelY, width, height, RIM ); } drawBubbles(s.bubbles, bubbleRefs.current, s.time); const shown = Math.round(clamp(s.level, 0, 100)); if (shown !== s.shown) { s.shown = shown; const used = ((shown / 100) * totalRef.current).toFixed(1); setText(percentRefs.current, String(shown)); setText(usedRefs.current, used); } }, []); const spawnBubbles = useCallback((dt: number) => { const s = sim.current; // Only a climbing level makes bubbles, more the faster it climbs. s.spawn += Math.max(0, s.levelSpeed) * BUBBLE_RATE * dt; while (s.spawn >= 1) { s.spawn -= 1; const free = s.bubbles.find((b) => !b.alive); if (!free) { s.spawn = 0; break; } free.alive = true; free.x = s.width * (0.12 + Math.random() * 0.76); free.y = s.height - 6; free.r = 1.5 + Math.random() * 2.5; free.speed = 70 + Math.random() * 80; free.phase = Math.random() * Math.PI * 2; } }, []); const advanceBubbles = useCallback((dt: number) => { const s = sim.current; const levelY = s.height * (1 - s.level / 100); const column = (x: number) => clamp(Math.round((x / s.width) * (COLUMNS - 1)), 0, COLUMNS - 1); let alive = 0; for (const bubble of s.bubbles) { if (!bubble.alive) { continue; } bubble.y -= bubble.speed * dt; const i = column(bubble.x); // At the surface it pops, and leaves a tiny dimple behind. if (bubble.y - bubble.r <= levelY + s.heights[i]) { bubble.alive = false; s.speeds[i] += bubble.r * 6; } else { alive++; } } return alive; }, []); const step = useCallback(() => { const s = sim.current; const { heights, speeds } = s; // Fill line spring. const force = (s.target - s.level) * LEVEL_STIFFNESS - s.levelSpeed * LEVEL_DAMPING; s.levelSpeed += force * STEP; s.level += s.levelSpeed * STEP; // Surface springs, with walls that reflect. let energy = Math.abs(s.target - s.level) + Math.abs(s.levelSpeed) * 0.1; for (let i = 0; i < COLUMNS; i++) { const left = heights[i > 0 ? i - 1 : i]; const right = heights[i < COLUMNS - 1 ? i + 1 : i]; const accel = (left + right - 2 * heights[i]) * SPREAD - heights[i] * RESTORE - speeds[i] * SURFACE_DAMPING; speeds[i] += accel * STEP; } for (let i = 0; i < COLUMNS; i++) { heights[i] = clamp(heights[i] + speeds[i] * STEP, -MAX_WAVE, MAX_WAVE); energy = Math.max( energy, Math.abs(heights[i]), Math.abs(speeds[i]) * 0.1 ); } s.time += STEP; return energy; }, []); const tick = useCallback( (now: number) => { const s = sim.current; s.raf = 0; const elapsed = s.last ? Math.min((now - s.last) / 1000, 0.05) : STEP; s.last = now; s.carry += elapsed; let energy = 0; while (s.carry >= STEP) { energy = Math.max(energy, step()); s.carry -= STEP; } spawnBubbles(elapsed); const bubbles = advanceBubbles(elapsed); draw(); const busy = s.pointerId >= 0 || bubbles > 0 || energy > REST_EPSILON; if (busy && s.visible) { s.raf = requestAnimationFrame(tick); } else { s.last = 0; if (!busy) { // Settle exactly, so the resting surface is flat. s.level = s.target; s.levelSpeed = 0; s.heights.fill(0); s.speeds.fill(0); draw(); } } }, [advanceBubbles, draw, spawnBubbles, step] ); const wake = useCallback(() => { const s = sim.current; if (!s.raf && s.visible) { s.raf = requestAnimationFrame(tick); } }, [tick]); // A new capacity rewrites the readout even when the level stays put. useEffect(() => { totalRef.current = total; sim.current.shown = -1; draw(); }, [total, draw]); // A new value: the spring takes it from here. useEffect(() => { const s = sim.current; if (s.target === value) { return; } s.target = value; if (s.reduceMotion) { s.level = value; s.levelSpeed = 0; draw(); return; } wake(); }, [value, draw, wake]); useEffect(() => { const s = sim.current; const root = rootRef.current; if (!root) { return; } const motion = window.matchMedia("(prefers-reduced-motion: reduce)"); s.reduceMotion = motion.matches; const onMotion = () => { s.reduceMotion = motion.matches; }; motion.addEventListener("change", onMotion); const resize = new ResizeObserver(([entry]) => { s.width = entry.contentRect.width; s.height = entry.contentRect.height; draw(); }); resize.observe(root); // Offscreen or in a background tab, the loop waits. const pause = () => { if (!s.visible && s.raf) { cancelAnimationFrame(s.raf); s.raf = 0; s.last = 0; } else if (s.visible) { wake(); } }; let onScreen = true; const intersection = new IntersectionObserver(([entry]) => { onScreen = entry.isIntersecting; s.visible = onScreen && !document.hidden; pause(); }); intersection.observe(root); const onVisibility = () => { s.visible = onScreen && !document.hidden; pause(); }; document.addEventListener("visibilitychange", onVisibility); return () => { motion.removeEventListener("change", onMotion); resize.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", onVisibility); cancelAnimationFrame(s.raf); s.raf = 0; }; }, [draw, wake]); const valueAt = (clientY: number) => { const rect = rootRef.current?.getBoundingClientRect(); if (!rect) { return value; } return (1 - (clientY - rect.top) / rect.height) * 100; }; // Push the columns around x by `amount`, fading out over a few columns. const disturb = (clientX: number, amount: number, reach = 3) => { const s = sim.current; const rect = rootRef.current?.getBoundingClientRect(); if (!rect || s.reduceMotion) { return; } const center = ((clientX - rect.left) / rect.width) * (COLUMNS - 1); for (let i = 0; i < COLUMNS; i++) { const falloff = 1 - Math.abs(i - center) / reach; if (falloff > 0) { s.speeds[i] += amount * falloff; } } }; const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => { if (readOnly || (event.pointerType === "mouse" && event.button !== 0)) { return; } const s = sim.current; event.currentTarget.setPointerCapture(event.pointerId); s.pointerId = event.pointerId; s.pointerX = event.clientX; setDragging(true); // The press itself lands like a drop. disturb(event.clientX, 260, 2.5); setValue(valueAt(event.clientY)); wake(); }; const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => { const s = sim.current; const dx = s.pointerX < 0 ? 0 : event.clientX - s.pointerX; s.pointerX = event.clientX; if (s.reduceMotion) { if (s.pointerId === event.pointerId) { setValue(valueAt(event.clientY)); } return; } if (s.pointerId === event.pointerId) { // A sideways drag rocks the whole body: one side rises, the other // falls, and the springs carry it back and forth. for (let i = 0; i < COLUMNS; i++) { s.speeds[i] += ((i / (COLUMNS - 1)) * 2 - 1) * dx * SLOSH; } setValue(valueAt(event.clientY)); wake(); return; } // Hovering through the liquid stirs it, behind the cursor. const rect = rootRef.current?.getBoundingClientRect(); if (event.pointerType !== "mouse" || !rect || dx === 0) { return; } const levelY = rect.height * (1 - s.level / 100); if (event.clientY - rect.top > levelY + 8) { disturb(event.clientX, Math.abs(dx) * STIR, 2); wake(); } }; const handlePointerUp = (event: PointerEvent<HTMLDivElement>) => { const s = sim.current; if (s.pointerId !== event.pointerId) { return; } s.pointerId = -1; setDragging(false); haptic(); wake(); }; const handlePointerLeave = () => { if (sim.current.pointerId < 0) { sim.current.pointerX = -1; } }; const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { if (readOnly) { return; } const steps: Record<string, number> = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1, PageUp: 10, PageDown: -10, }; let next: number | undefined; if (event.key in steps) { next = value + steps[event.key] * (event.shiftKey ? 10 : 1); } else if (event.key === "Home") { next = 0; } else if (event.key === "End") { next = 100; } if (next === undefined) { return; } event.preventDefault(); // A keyed change still lands with a small ripple. const rect = rootRef.current?.getBoundingClientRect(); if (rect) { disturb(rect.left + rect.width / 2, next > value ? 120 : -120, 4); } setValue(next); wake(); }; const warn = value >= warnAt; const status = warn ? "Almost full" : `${100 - Math.round(value)}% free`; const used = ((Math.round(value) / 100) * total).toFixed(1); const textProps = { label, status, value: initial, used: ((Math.round(initial) / 100) * total).toFixed(1), total, unit, }; return ( <div aria-label={label} aria-orientation="vertical" aria-readonly={readOnly || undefined} aria-valuemax={100} aria-valuemin={0} aria-valuenow={Math.round(value)} aria-valuetext={`${Math.round(value)}%, ${used} of ${total} ${unit} used`} className={cn( "relative h-[22.5rem] w-72 touch-none select-none overflow-hidden rounded-3xl border border-zinc-200 bg-white text-zinc-900 outline-none dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100", "focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-950", !readOnly && (dragging ? "cursor-grabbing" : "cursor-ns-resize"), className )} onKeyDown={handleKeyDown} onLostPointerCapture={handlePointerUp} onPointerCancel={handlePointerUp} onPointerDown={handlePointerDown} onPointerLeave={handlePointerLeave} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} ref={rootRef} role="slider" tabIndex={readOnly ? -1 : 0} > <GaugeText {...textProps} percentRef={(node) => { percentRefs.current[0] = node; }} usedRef={(node) => { usedRefs.current[0] = node; }} /> {/* The meniscus, a lighter band just above the liquid. */} <div aria-hidden="true" className={cn( "pointer-events-none absolute inset-0 transition-colors duration-300 ease-out", warn ? "bg-red-300 dark:bg-red-900" : "bg-zinc-300 dark:bg-zinc-600" )} ref={rimRef} style={{ clipPath: flatPolygon(initial, RIM) }} /> {/* The liquid, with a second copy of the text in its own colour. */} <div aria-hidden="true" className={cn( "pointer-events-none absolute inset-0 transition-colors duration-300 ease-out", warn ? "bg-red-500 text-white" : "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900" )} ref={liquidRef} style={{ clipPath: flatPolygon(initial) }} > <GaugeText {...textProps} percentRef={(node) => { percentRefs.current[1] = node; }} usedRef={(node) => { usedRefs.current[1] = node; }} /> {Array.from({ length: BUBBLES }, (_, i) => ( <span className="absolute top-0 left-0 size-2 rounded-full border border-current bg-current/20 opacity-0" key={`bubble-${i.toString()}`} ref={(node) => { bubbleRefs.current[i] = node; }} /> ))} </div> </div> ); }
Feedback
Questions, bugs or ideas for Liquid Gauge? Post them here, or reply to someone else. Sign in with GitHub to comment.