---
name: liquid-loader
description: Use when you want "Creative, playful, premium" - Uses fluid movement to show loading progress.
---

# Liquid Loader

> **Category:** Loading / Transition  -  **Personality:** Creative, playful, premium
>
> **Best use:** Creative products, upload states
>
> **Ratings:** Professional 3/5 - Casual 5/5 - Exotic 4/5 - Visual impact 4/5 - Difficulty 4/5 - Performance cost 3/5

## What it is

A progress indicator that reads as a **vessel filling with rippling liquid** rather than a flat bar. The fill level is driven by a single CSS custom property, and a wavy surface plus rising bubbles sell the "fluid" feel. It is perfect for moments that benefit from a little delight — file uploads, exports, render queues, onboarding — in creative or consumer products where a plain spinner would feel cold. Replayable on demand: the demo simulates a realistic upload (fast start, a stall near two-thirds, then completion) and resets so you can watch it again.

## Dependencies / CDN

None - pure CSS + inline SVG + vanilla JS. The demo loads two display/body fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Onest:wght@400;500;600;700&family=Unbounded:wght@500;600;700&display=swap" rel="stylesheet">
```

## HTML

The effect itself is the circular gauge. Everything else (brand, file row, track, buttons) is just realistic context.

```html
<div class="ll-gauge" role="progressbar" aria-label="Upload progress"
     aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
  <!-- the liquid: a full-height block pinned to the bottom, slid by --p -->
  <div class="ll-liquid" data-liquid>
    <!-- two scrolling sine-wave surfaces (same path, different speed/opacity) -->
    <svg class="ll-wave ll-wave-back" viewBox="0 0 1200 60" preserveAspectRatio="none" aria-hidden="true"><path d="M0,22 C50,8 100,8 150,22 C200,36 250,36 300,22 C350,8 400,8 450,22 C500,36 550,36 600,22 C650,8 700,8 750,22 C800,36 850,36 900,22 C950,8 1000,8 1050,22 C1100,36 1150,36 1200,22 L1200,60 L0,60 Z"/></svg>
    <svg class="ll-wave ll-wave-front" viewBox="0 0 1200 60" preserveAspectRatio="none" aria-hidden="true"><path d="M0,22 C50,8 100,8 150,22 C200,36 250,36 300,22 C350,8 400,8 450,22 C500,36 550,36 600,22 C650,8 700,8 750,22 C800,36 850,36 900,22 C950,8 1000,8 1050,22 C1100,36 1150,36 1200,22 L1200,60 L0,60 Z"/></svg>
    <!-- bubbles only animate while .is-running -->
    <span class="ll-bubble" style="left:24%;width:7px;height:7px;--bd:3.8s;--bdl:.2s"></span>
    <span class="ll-bubble" style="left:62%;width:9px;height:9px;--bd:3.2s;--bdl:.6s"></span>
  </div>
  <!-- centered readout sits above the liquid, never intercepts clicks -->
  <div class="ll-readout">
    <span class="ll-pct"><span data-pct>0</span><i>%</i></span>
    <span class="ll-readlabel">
      <svg class="ll-tick" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12.5l5 5L20 6"/></svg>
      <span data-rlabel>uploaded</span>
    </span>
  </div>
</div>
```

## CSS

```css
.ll-gauge{position:relative;width:clamp(192px,44vw,252px);aspect-ratio:1;border-radius:50%;
  overflow:hidden;                              /* clips the 200%-wide waves to the vessel */
  --liq-top:#57f0d8;--liq-bot:#7b6bff;
  background:radial-gradient(circle at 50% 36%,#0d1832,#060c1b);
  border:1px solid rgba(255,255,255,.1);
  box-shadow:inset 0 2px 20px rgba(0,0,0,.55),0 26px 64px -26px rgba(7,12,28,.95)}

/* THE FILL: --p (0..100) slides the liquid down (empty) or up (full).
   6 = a small resting puddle so an "empty" vessel still shows liquid;
   0.94 scales the rest so --p:100 reaches the brim. */
.ll-liquid{position:absolute;left:-2px;right:-2px;bottom:0;height:100%;will-change:transform;
  background:linear-gradient(180deg,var(--liq-top) 0%,#41b6f2 44%,var(--liq-bot) 100%);
  transform:translateY(calc(100% - (6 + var(--p) * 0.94) * 1%));
  transition:transform .35s cubic-bezier(.4,.85,.4,1)}

/* THE SURFACE: SVG sine paths sit just above the liquid and scroll sideways.
   Width 200% + a whole number of periods → translateX(-50%) loops seamlessly. */
.ll-wave{position:absolute;bottom:100%;left:0;width:200%;height:22px;display:block}
.ll-wave-back{height:27px;opacity:.5;fill:#41b6f2;animation:ll-wave-x 6.2s linear infinite reverse}
.ll-wave-front{fill:#6ff6e0;animation:ll-wave-x 3.4s linear infinite}
@keyframes ll-wave-x{to{transform:translateX(-50%)}}

.ll-bubble{position:absolute;bottom:7%;border-radius:50%;background:rgba(255,255,255,.42);animation:none;opacity:0}
.ll-gauge.is-running .ll-bubble{animation:ll-rise var(--bd,4s) linear var(--bdl,0s) infinite}
@keyframes ll-rise{0%{transform:translateY(0) scale(.5);opacity:0}14%{opacity:.6}
  82%{opacity:.45}100%{transform:translateY(-150px) scale(1.05);opacity:0}}

/* readout: above the liquid, click-through, legible over dark AND bright fill */
.ll-readout{position:absolute;inset:0;z-index:3;display:flex;flex-direction:column;
  align-items:center;justify-content:center;gap:3px;text-align:center;pointer-events:none}
.ll-pct{font:700 clamp(42px,9.5vw,54px)/1 'Onest',system-ui,sans-serif;color:#f5fdff;
  font-variant-numeric:tabular-nums;text-shadow:0 2px 16px rgba(4,10,24,.6),0 0 2px rgba(4,10,24,.5)}
.ll-pct i{font-style:normal;font-size:.46em;margin-left:2px;opacity:.85;vertical-align:super}

/* completion: the check scales in, the number gives a little pop */
.ll-tick{width:0;height:13px;fill:none;stroke:#67e3a0;stroke-width:3;stroke-linecap:round;
  opacity:0;transform:scale(.4);transition:width .3s,opacity .3s .05s,transform .3s .05s,margin .3s}
.ll-gauge.is-done .ll-tick{width:13px;margin-right:6px;opacity:1;transform:scale(1)}
.ll-gauge.is-done .ll-pct{animation:ll-pop .55s ease}
@keyframes ll-pop{0%{transform:scale(1)}42%{transform:scale(1.08)}100%{transform:scale(1)}}

@media (prefers-reduced-motion:reduce){
  .ll-wave-front,.ll-wave-back{animation:none}
  .ll-gauge.is-running .ll-bubble{animation:none;opacity:0}
  .ll-liquid{transition:none}            /* fill snaps instead of sliding */
  .ll-gauge.is-done .ll-pct{animation:none}
}
```

## JavaScript

The script only sets `--p` over time and mirrors it into the number / track / counter / ARIA. Everything visual lives in CSS.

```js
(function(){
  var gauge  = document.querySelector('.ll-gauge'),
      liquid = gauge.querySelector('[data-liquid]'),
      pctEl  = gauge.querySelector('[data-pct]');
  var TOTAL = 248, DURATION = 4200, raf = null, startTime = 0, state = 'idle';
  var reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;

  // a believable upload curve: fast start, a stall ~63-66%, then finish
  var KEYS = [[0,0],[.10,26],[.30,52],[.46,63],[.58,66],[.74,90],[.88,97],[1,100]];
  function curve(t){
    if(t<=0)return 0; if(t>=1)return 100;
    for(var i=1;i<KEYS.length;i++) if(t<=KEYS[i][0]){
      var a=KEYS[i-1],b=KEYS[i]; return a[1]+(b[1]-a[1])*((t-a[0])/(b[0]-a[0]));
    }
    return 100;
  }
  function setP(p){
    p = Math.max(0, Math.min(100, p));
    liquid.style.setProperty('--p', p.toFixed(1));   // drives the fill
    pctEl.textContent = Math.round(p);
    gauge.setAttribute('aria-valuenow', Math.round(p));
    // ...also update the thin track width and the "X / 248 MB" counter
  }
  function complete(){ cancelAnimationFrame(raf); state='done';
    gauge.classList.remove('is-running'); gauge.classList.add('is-done'); setP(100); }
  function frame(now){
    var t=(now-startTime)/DURATION;
    if(t>=1){ complete(); return; }
    setP(curve(t)); raf=requestAnimationFrame(frame);
  }
  function start(){
    if(state==='running')return;
    state='running';
    if(reduce){ complete(); return; }          // reduced motion: jump to done, no loop
    gauge.classList.add('is-running');
    startTime=performance.now(); raf=requestAnimationFrame(frame);
  }
  // wire start() / reset() to your buttons; reset() sets --p back to 0 + clears classes
})();
```

## How it works

- **One variable does the work.** `--p` (0-100) feeds `transform: translateY(calc(100% - (6 + var(--p) * 0.94) * 1%))`. At `--p:0` the liquid is pushed almost entirely below the vessel (a 6% puddle remains); at `--p:100` it slides up to the brim. Animating `transform` (not `height`/`top`) keeps it on the compositor.
- **The wavy surface is two scrolling SVGs.** Each `<path>` draws several full sine periods across a `1200`-wide viewBox and fills down to the bottom. The `<svg>` is `200%` wide and animates `translateX` to `-50%` (exactly one container width = a whole number of periods), so the loop is seamless. A slower, reversed, semi-transparent **back** wave behind a faster **front** wave gives parallax depth and a glossy meniscus.
- **`overflow:hidden` on the round container** clips the oversized waves and crests into the vessel shape — that single line is what turns a rectangle of moving water into "liquid in a circle".
- **Bubbles** are absolutely-positioned dots running a rise-and-fade keyframe; they only animate while the gauge has `.is-running`, so the idle state stays calm.
- **JS is a thin driver:** a `requestAnimationFrame` loop maps elapsed time through a small keyframe curve into `--p`, and mirrors that into the percentage, the supporting track bar, the MB counter and `aria-valuenow`. On completion it swaps to `.is-done` (check + pop).

## Customization

- **Vessel shape:** the fill logic is shape-agnostic. Swap `border-radius:50%` for a rounded rectangle (a tank/pill), a bottle silhouette via `clip-path`, or any glyph by clipping the gauge to an SVG/text mask.
- **Palette:** change `--liq-top` / `--liq-bot` on `.ll-gauge` and the two `fill` colours on the waves. Keep the front wave slightly lighter than the body top for the glossy waterline.
- **Wave character:** amplitude = the control-point Y in the path (it swings between `8` and `36` around the `22` midline); choppiness = `.ll-wave` `height` and the two animation durations. Slower + lower = calm tide; faster + taller = energetic.
- **Resting level / brim:** tune the `6` (idle puddle) and `0.94` (range) constants. Want the surface to always cover the very top at 100%? Make it overfill slightly, e.g. `(6 + var(--p) * 0.97) * 1%`.
- **Loop instead of trigger:** drive `--p` with a CSS `@keyframes 0→100` animation, or simply restart the JS loop, for an indeterminate "always sloshing" loader.

## Accessibility & performance

- Expose progress to assistive tech: `role="progressbar"` with `aria-valuemin/max` and a live-updated `aria-valuenow` (the demo updates it every frame).
- **`prefers-reduced-motion`:** the CSS stops the wave scroll, bubbles and the fill transition; the JS checks `matchMedia` **before** starting the rAF loop and instead jumps straight to the final value. No infinite motion ever runs for those users.
- Only `transform` / `opacity` / `width` are animated — no layout thrash. A single `requestAnimationFrame` loop runs during the upload and is cancelled on completion/reset.
- `font-variant-numeric: tabular-nums` on the percentage keeps its width stable so the number doesn't jitter as it counts up.
- The readout is `pointer-events:none` and carries a `text-shadow`, so it stays readable over both the dark empty vessel and the bright liquid without blocking clicks.

## Gotchas

- **Forgetting `overflow:hidden`** on the container lets the `200%`-wide waves spill out and create a horizontal scrollbar. It is mandatory, not decorative.
- **The wave must tile.** The path needs a *whole number* of periods across its width and the keyframe must shift by exactly `-50%` (one container width); any other amount makes the loop visibly jump. Use `preserveAspectRatio="none"` so the path stretches to the thin band.
- **calc unit math:** `var(--p)` is unitless, so `(6 + var(--p) * 0.94) * 1%` is what converts it to a percentage — drop the `* 1%` and the whole `calc()` becomes invalid and the transform is ignored.
- **The wavy surface reads a touch fuller than `--p`** because the crests sit a few px above the flat fill line. At exactly 100% the waves clip above the brim and the top goes flat — a clean "settled" look. If you need the brim covered at every sub-100 value, use the slight-overfill mapping above.
- **Don't animate the waves with layout properties** (`top`, `margin`, `background-position` on a huge element) — keep them on `transform` so they stay GPU-composited.
