Wave Loader — Liquid Fill Loader HTML CSS JS SVG
Wave Loader · Loaders · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="wl-stage">
<div class="wl-orb">
<svg class="wl-svg" viewBox="0 0 120 120">
<defs><clipPath id="wlClip"><circle cx="60" cy="60" r="56"/></clipPath></defs>
<circle cx="60" cy="60" r="56" class="wl-ring"/>
<g clip-path="url(#wlClip)">
<rect x="0" y="0" width="120" height="120" class="wl-fill" id="wlFill"/>
<path class="wl-wave" id="wlWave1"/>
<path class="wl-wave wl-wave2" id="wlWave2"/>
</g>
</svg>
<div class="wl-pct" id="wlPct">0%</div>
</div>
<input type="range" id="wlRange" min="0" max="100" value="65" aria-label="Fill level">
</div>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0f172a;min-height:100vh;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:26px}
.wl-orb{position:relative;width:160px;height:160px}
.wl-svg{width:160px;height:160px}
.wl-ring{fill:#0b1220;stroke:#1e293b;stroke-width:3}
.wl-fill{fill:#1d4ed8;opacity:.25}
.wl-wave{fill:#3b82f6;opacity:.85}
.wl-wave2{fill:#60a5fa;opacity:.55}
/* The two waves slide horizontally at different speeds for a layered liquid look. */
#wlWave1{animation:wlMove 2.4s linear infinite}
#wlWave2{animation:wlMove 3.6s linear infinite reverse}
@keyframes wlMove{from{transform:translateX(0)}to{transform:translateX(-120px)}}
.wl-pct{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:30px;font-weight:800;color:#fff;text-shadow:0 1px 6px rgba(0,0,0,.5);font-variant-numeric:tabular-nums}
input[type=range]{width:200px;accent-color:#3b82f6}var range = document.getElementById('wlRange');
var pct = document.getElementById('wlPct');
var SVGNS = 'http://www.w3.org/2000/svg';
// Build a sine wave path twice as wide as the orb so it can slide seamlessly.
function wavePath(level, amp, len) {
// level 0..1 → baseline y (full at top when level=1). The path is 240 wide
// (2x the 120 viewBox) so translating by -120 loops perfectly.
var baseY = 120 - level * 120;
var d = 'M0,' + baseY.toFixed(1);
for (var x = 0; x <= 240; x += 6) {
var y = baseY + Math.sin((x / len) * Math.PI * 2) * amp;
d += ' L' + x + ',' + y.toFixed(1);
}
d += ' L240,120 L0,120 Z';
return d;
}
function update() {
var level = +range.value / 100;
document.getElementById('wlWave1').setAttribute('d', wavePath(level, 5, 60));
document.getElementById('wlWave2').setAttribute('d', wavePath(level, 7, 80));
document.getElementById('wlFill').setAttribute('y', (120 - level * 120).toFixed(1));
document.getElementById('wlFill').setAttribute('height', (level * 120).toFixed(1));
pct.textContent = range.value + '%';
}
range.addEventListener('input', update);
update();Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Wave Loader — A Liquid-Fill Orb with Two Animated Sine Waves

The wave (or liquid-fill) loader shows progress as liquid rising in a container, its surface rippling with animated waves — a richer, more organic alternative to a plain bar or spinner. This snippet builds it in plain HTML, CSS, SVG, and vanilla JavaScript: a circular orb that fills to any percentage with two layered sine waves sliding across the surface — no library.
Waves drawn as sine paths
The wave surface is an SVG path generated from the sine function: wavePath() walks across the width sampling sin() to build the rippled top edge, then closes the path down to the bottom to form a filled body of liquid. The path is drawn twice as wide as the orb (240 across a 120 viewBox) so it can slide a full wavelength and loop seamlessly. Generating the wave from sin() (rather than a static image) means the amplitude, wavelength, and fill level are all parameters you control.
Two layers for depth
Two wave paths are stacked — one taller-amplitude and more opaque, one shorter and translucent — animated at different speeds and opposite directions. This parallax of two offset waves is what makes the liquid look alive and three-dimensional rather than a single flat ripple; it is the same trick used in app liquid-progress widgets. A faint static fill rectangle behind them gives the body colour.
Clipped to the container shape
Everything inside the orb is wrapped in a clipPath of the circle, so the rectangular waves and fill are masked to the round shape — the liquid appears to sit inside the orb with a clean curved edge. Swapping the clip shape (a rounded rect, a logo path) reshapes the container without touching the wave logic, which is the flexibility the clip approach gives.
Level-driven fill
The fill level (0–100%) sets the waves' baseline y — at 100% the surface is at the top, at 0% at the bottom — and a percentage label sits centred over the orb. Here a slider drives the level live so you can see it fill and drain; in real use you would set the level from your actual progress (upload percent, score, capacity). The horizontal sliding is pure CSS animation, independent of the level, so the liquid keeps rippling at any fill.
Drop-in and adaptable
Tune the wave amplitude, wavelength, colours, and speeds, or change the clip shape to fit your design. It is a complete, dependency-free reference for sine-wave path generation, layered wave parallax, and clip-path container masking — the foundation of any liquid-fill loader or gauge. Because update() rewrites the d attribute on every change rather than only animating the CSS slide, regenerating the path is the right place to put any easing if you switch the level from a slider to programmatic progress updates — wrap the level changes in a short transition loop (or animate a JS value over a few hundred milliseconds and call update() each frame) so the surface rises smoothly instead of jumping straight to the new height.
Step by step
How to Use
- 1Paste HTML, CSS, and JSA liquid orb renders, filled with two rippling waves, and a slider to set the level.
- 2Drag the sliderChange the fill level 0-100% and watch the liquid rise or drain while the waves ripple.
- 3Drive from real progressSet range.value (or call update with your level) from upload percent, score, or capacity.
- 4Tune the wavesAdjust the amplitude, wavelength, and animation speeds for calmer or choppier liquid.
- 5Reshape the containerChange the clipPath shape to a rounded rect or logo to fill a different outline.
- 6Restyle itEdit the wave colours, body fill, and ring to match your theme.
Real-world uses
Common Use Cases
sin() so amplitude and wavelength stay adjustable.Got questions?
Frequently Asked Questions
wavePath() walks across the width in small steps, computing each point's y as the fill baseline plus sin(x) × amplitude, building an SVG path of the rippled surface, then closes it down to the bottom to form a filled body. Because it is generated from sin() rather than a fixed image, you control the amplitude (wave height), wavelength, and baseline (fill level) as parameters.
A single ripple looks flat. Stacking two waves — one taller and more opaque, one shorter and translucent — animated at different speeds and opposite directions creates a parallax that reads as real, three-dimensional liquid. This layered-wave technique is what app liquid-progress widgets use to make the fill feel alive at any level.
The wave path is drawn twice as wide as the visible orb (240 across a 120 viewBox). The CSS animation translates it left by exactly one orb-width (120px), so when it resets, the second half of the wave has moved into the position the first half started in — a continuous sine pattern means the wave looks identical, and the loop is seamless.
The fill level comes from the slider's 0-100 value, which sets the wave baseline and the static fill height. In real use, set that level from your actual progress — assign range.value or call update() with your percentage from an upload's loaded/total, a score, or a capacity figure. The wave ripple animation is independent of the level, so it keeps moving as the liquid rises.
Hold the fill level in state and recompute the wave path d attributes from it in the render (or imperatively via a ref). In React use useState and set the paths in an effect or inline; in Vue bind :d with a computed; in Angular bind [attr.d] with a getter. The wavePath() sine math and the CSS ripple animation are framework-agnostic — only the level state moves into the framework.