webdevpuneet.com

Liquid Fill Progress Indicator — Wavy SVG Loader in HTML CSS JS

Liquid Fill Progress Indicator · Loaders · Plain HTML, CSS & JS · Live preview

CategoryLoaders

Copy & paste

Source Code

<div class="lf-stage">
  <div class="lf-tank" id="lfTank" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Fill progress">
    <svg class="lf-wave" viewBox="0 0 200 200" preserveAspectRatio="none">
      <path id="lfWavePath" d=""></path>
    </svg>
    <span class="lf-pct" id="lfPct">0%</span>
  </div>
  <div class="lf-controls">
    <button type="button" class="lf-btn" id="lfMinus">−10%</button>
    <button type="button" class="lf-btn lf-primary" id="lfPlus">+10%</button>
    <button type="button" class="lf-btn" id="lfReset">Reset</button>
  </div>
</div>

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.

· Become a supporter

What's included

Features

Real sine-wave surface
An SVG path resampled every frame — a genuine undulation, not a static wavy image.
Independent level and motion
Fill height is a real percentage; the wave's roll is a separate, continuous system.
Holds and still moves
The surface keeps animating even while progress is unchanged.
Smooth level transitions
A cubic-bezier transform eases the tank between percentages.
Accessible progressbar role
aria-valuenow tracks the real value on every render.
Gradient liquid fill
A cyan-to-indigo SVG gradient gives the liquid depth.
Tunable physics
AMPLITUDE and WAVELENGTH constants control the wave's character.
No dependencies
Pure SVG, CSS, and vanilla JS — no canvas library or animation engine.

About this UI Snippet

Liquid Fill Progress Indicator — A Tank That Fills With a Genuinely Undulating Surface

Screenshot of the Liquid Fill Progress Indicator snippet rendered live

A liquid fill indicator shows progress as rising liquid inside a container rather than a bar or ring — a shape more associated with tanks, gauges, and playful onboarding or gamified UIs than a typical progress bar. The part that makes it convincing is the surface: a flat rectangle sliding up reads as a fake liquid, so this snippet draws the top edge as a real sine wave in an SVG path and keeps it moving continuously, even when the fill level is holding steady.

A sine wave, not a straight edge

The liquid's surface is an SVG <path> rebuilt every frame inside a requestAnimationFrame loop. Twenty-four points are sampled across the tank's width, each offset vertically by Math.sin((x / WAVELENGTH) * Math.PI * 2 + t), where t increments a little every frame. That constant increment is what keeps the wave rolling sideways forever — the surface never goes flat, whether progress is at 20% or sitting untouched at 70% for a minute.

The fill level is a real value

Separately from the wave's own motion, the whole SVG element is translated vertically by translateY(100 - progress + '%'), computed directly from a progress variable you control (0–100). This is the actual liquid level: incrementing progress with the demo buttons animates the tank rising or falling with a smooth cubic-bezier transition, while the sine wave keeps undulating on top of wherever that level currently sits — two independent motions (level and surface) composed together, which is what separates this from a bar with a wavy PNG background.

Two separate motions, cleanly split

Keeping "how full" (a CSS transform driven by a real percentage) and "how the surface moves" (a continuously regenerated SVG path) as two independent systems is the key architectural decision here. It means you can drive progress from a real value — an upload's loaded/total, a task counter, a battery level — while the wave animation runs untouched in its own rAF loop, never needing to know what the percentage is.

Accessible and swappable

The tank carries role="progressbar" with aria-valuenow kept in sync with the real percentage on every render, so assistive tech reports genuine progress, not just the visual. Swap the pill-shaped tank for a circle, change the gradient stops, or tune AMPLITUDE/WAVELENGTH for calmer or choppier water. Pair it with a circular progress ring for a numeric companion, or a quota usage meter for a dashboard context.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSA tank renders empty with a percentage label at 0%.
  2. 2
    Click +10% / −10%The liquid level rises or falls with a smooth transition to the real value.
  3. 3
    Watch the surface at restEven holding steady, the wave keeps rolling — it never goes flat.
  4. 4
    Drive it from real dataSet the progress variable from your actual percentage and call render().
  5. 5
    Tune the waveAdjust AMPLITUDE and WAVELENGTH for calmer or choppier motion.
  6. 6
    Restyle the tankChange the border-radius, gradient stops, or container shape.

Real-world uses

Common Use Cases

Gamified onboarding progress
Offer a playful alternative to a progress bar, where a tank fills as new users complete setup.
Battery and resource gauges
Show charge or capacity next to a quota usage meter, with a wave that keeps rolling even while the level holds.
Hydration and goal trackers
Provide a literal water-themed visual for hydration goals, with a cubic-bezier transform easing the level smoothly between values.
Upload and download levels
Pair with an upload progress bar for a more characterful take on transfer status, using the tank for the overall level.
Wave versus ring comparisons
Compare with a circular progress ring, noting that here the SVG path is resampled every frame for genuine undulation.
Related: Button Loading State Morph
See the Button Loading State Morph for a related loaders pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

The wave's shape and the fill level are two separate systems. A requestAnimationFrame loop increments a time variable t every frame and rebuilds the SVG path from Math.sin(x / WAVELENGTH * 2π + t) regardless of the progress value, so the surface keeps rolling sideways continuously. The fill level is a separate CSS transform driven only by the progress variable, so it only changes when you actually update progress.

A static background image repeats identically every cycle and cannot be resized without artifacts. Rebuilding the path from real trigonometry each frame means the wave scales cleanly to any tank width, the amplitude and wavelength are tunable numbers rather than a fixed asset, and the motion has no visible seam or restart point.

Set the progress variable to your real value (0-100) — for example from a fetch/XHR progress event's loaded/total — and call render(). The translateY transform and the aria-valuenow both update from that same variable, so the visual and the accessible value never drift apart.

Lower AMPLITUDE for a calmer, flatter surface or raise it for more dramatic waves. Increase WAVELENGTH for slower, broader swells or decrease it for tighter ripples. Changing the 0.06 increment on t controls how fast the surface scrolls sideways.

Keep the rAF wave-drawing loop in a mount effect with cleanup (cancelAnimationFrame on unmount), and drive the fill level from component state instead of a bare variable — update the transform whenever your progress prop or state changes. The SVG path generation logic is framework-agnostic and ports unchanged.