# Liquid Progress

> A progress bar filled with liquid: each jump surges forward and the rounded front wobbles before it settles, and when there's no telling how long it'll take, a slug of it sloshes end to end.

- Page: https://devl.dev/components/liquid-progress
- Kind: component · Liquid
- Install: `npx shadcn@latest add https://devl.dev/r/liquid-progress.json`
- Raw source: https://devl.dev/raw/liquid-progress.tsx
- npm: motion
- License: MIT. The coss ui components it uses (https://coss.com/ui) are AGPL-3.0.

## Usage

```tsx
"use client";

import { CheckIcon, DownloadIcon, PackageIcon, RotateCcwIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { LiquidProgress } from "@/components/liquid-progress";

const TOTAL_GB = 2.4;
const CHUNKS = [6, 11, 3, 14, 9, 2, 17, 8, 12, 5, 13];

export default function LiquidProgressDemo() {
  const [run, setRun] = useState(0);
  const [step, setStep] = useState(0);
  const progress = Math.min(CHUNKS.slice(0, step).reduce((a, b) => a + b, 0), 100);
  const done = progress >= 100;
  const attachmentsCounted = step >= 4;

  useEffect(() => {
    if (done) return;
    const id = window.setTimeout(() => setStep((s) => s + 1), step === 0 ? 500 : 650);
    return () => window.clearTimeout(id);
  }, [step, done, run]);

  const restart = () => {
    setStep(0);
    setRun((r) => r + 1);
  };

  const rows = [
    { label: "Pages", value: Math.min(100, progress * 1.3), meta: `${Math.round(Math.min(1, (progress * 1.3) / 100) * 1800).toLocaleString("en-US")} / 1,800` },
    { label: "Attachments", value: attachmentsCounted ? Math.max(0, (progress - 30) * 1.43) : null, meta: attachmentsCounted ? "312 files" : "Counting…" },
    { label: "Comments", value: Math.min(100, progress * 2.2), meta: progress * 2.2 >= 100 ? "Done" : "Copying…" },
  ];

  return (
    <div className="grid w-96 max-w-full gap-4 rounded-2xl border bg-card p-5 shadow-xs/5">
      <div className="flex items-start gap-3">
        <span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
          {done ? <CheckIcon className="size-4 text-success-foreground" /> : <PackageIcon className="size-4" />}
        </span>
        <div className="grid min-w-0 flex-1 gap-0.5">
          <p className="truncate font-medium text-sm">{done ? "Your export is ready" : "Exporting Northwind workspace"}</p>
          <p className="text-muted-foreground text-xs tabular-nums">
            {done
              ? `${TOTAL_GB} GB · northwind-2026-10-24.zip`
              : `${((progress / 100) * TOTAL_GB).toFixed(1)} GB of ${TOTAL_GB} GB · about ${Math.max(1, Math.round((100 - progress) / 14))}s left`}
          </p>
        </div>
      </div>
      <LiquidProgress aria-label="Export progress" showValue size="lg" tone={done ? "success" : "primary"} value={progress} />
      <div className="grid gap-3 border-t pt-4">
        {rows.map((row) => (
          <div className="grid grid-cols-[6.5rem_1fr_5.5rem] items-center gap-3 text-xs" key={row.label}>
            <span className="text-muted-foreground">{row.label}</span>
            <LiquidProgress aria-label={row.label} size="sm" value={row.value === null ? null : Math.min(100, row.value)} />
            <span className="text-right text-muted-foreground tabular-nums">{row.meta}</span>
          </div>
        ))}
      </div>
      <div className="flex justify-end gap-2">
        {done ? (
          <>
            <Button onClick={restart} size="sm" variant="ghost">
              <RotateCcwIcon />
              Run again
            </Button>
            <Button size="sm">
              <DownloadIcon />
              Download
            </Button>
          </>
        ) : (
          <Button onClick={restart} size="sm" variant="ghost">
            Restart
          </Button>
        )}
      </div>
    </div>
  );
}
```

## Props

| Prop | Type | Default |
| --- | --- | --- |
| `value` | `number \| null` |  |
| `size`? | `keyof typeof HEIGHTS` | `"default"` |
| `tone`? | `keyof typeof TONES` | `"primary"` |
| `showValue`? | `boolean` | `false` |
| `className`? | `string` |  |
| `aria-label`? | `string` |  |

## Source: components/liquid-progress.tsx

```tsx
"use client";

import {
  motion,
  useAnimationFrame,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
  useVelocity,
} from "motion/react";
import { useId, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";

const TONES = {
  primary: "[--liquid:var(--color-primary)] [--liquid-ink:var(--color-primary-foreground)]",
  info: "[--liquid:var(--color-info)] [--liquid-ink:var(--color-white)]",
  success: "[--liquid:var(--color-success)] [--liquid-ink:var(--color-white)]",
} as const;

const HEIGHTS = { sm: 6, default: 10, lg: 28 } as const;

export interface LiquidProgressProps {
  value: number | null;
  size?: keyof typeof HEIGHTS;
  tone?: keyof typeof TONES;
  showValue?: boolean;
  className?: string;
  "aria-label"?: string;
}

function body(front: number, height: number, amp: number, t: number) {
  if (front <= 0.5) return "";
  const r = height / 2;
  const cx = front - r;
  let d = `M -2 -2 L ${cx.toFixed(2)} -2`;
  for (let i = 0; i <= 16; i++) {
    const theta = -Math.PI / 2 + (i / 16) * Math.PI;
    const push = amp * (0.8 + 0.25 * Math.sin(theta * 2 + t));
    const x = cx + Math.cos(theta) * (r + push);
    const y = r + Math.sin(theta) * r;
    d += ` L ${x.toFixed(2)} ${y.toFixed(2)}`;
  }
  return `${d} L ${cx.toFixed(2)} ${height + 2} L -2 ${height + 2} Z`;
}

export function LiquidProgress({
  value,
  size = "default",
  tone = "primary",
  showValue = false,
  className,
  "aria-label": ariaLabel = "Progress",
}: LiquidProgressProps) {
  const ref = useRef<HTMLDivElement>(null);
  const [width, setWidth] = useState(0);
  const reduce = useReducedMotion();
  const h = HEIGHTS[size];
  const indeterminate = value === null;
  const pct = indeterminate ? 0 : Math.min(Math.max(value, 0), 100);
  const filterId = `liquid-progress-${useId().replace(/:/g, "")}`;

  useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    const measure = () => setWidth(el.offsetWidth);
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  const front = useSpring(0, { stiffness: 110, damping: 20, mass: 1 });
  const velocity = useVelocity(front);
  const t = useMotionValue(0);
  const placed = useRef(false);

  useLayoutEffect(() => {
    if (!width) return;
    const to = (pct / 100) * width;
    if (!placed.current || reduce) front.jump(to);
    else front.set(to);
    placed.current = true;
  }, [pct, width, reduce, front]);

  useAnimationFrame((time) => {
    if (reduce || indeterminate || Math.abs(velocity.get()) < 1) return;
    t.set(time / 1000);
  });

  const amp = () => {
    if (reduce || front.get() >= width - 0.5) return 0;
    return Math.min(Math.abs(velocity.get()) / 40, h * 0.32);
  };
  const frontPath = useTransform(() => body(front.get(), h, amp(), t.get() * 9));
  const inkClip = useTransform(front, (f) => `inset(0 ${Math.max(width - f, 0)}px 0 0 round 0 ${h / 2}px ${h / 2}px 0)`);

  const slugX = useMotionValue(0);
  const slugW = useMotionValue(0);
  const dropX = useMotionValue(0);
  useAnimationFrame((time) => {
    if (!indeterminate || !width) return;
    const period = 2.2;
    const base = width * 0.32;
    const phase = reduce ? 0.25 : ((time / 1000) % period) / period;
    const at = (p: number) => (1 - Math.cos(p * Math.PI * 2)) / 2;
    const swing = Math.sin(phase * Math.PI * 2);
    const w = base * (1 + 0.45 * Math.abs(swing));
    const x = at(phase) * (width - w);
    slugW.set(w);
    slugX.set(x);
    const trailing = swing > 0 ? x + h * 0.2 : x + w - h * 1.2;
    dropX.set(trailing - swing * h * 1.5);
  });

  const percent = Math.round(pct);

  return (
    <div
      aria-busy={indeterminate || undefined}
      aria-label={ariaLabel}
      aria-valuemax={100}
      aria-valuemin={0}
      aria-valuenow={indeterminate ? undefined : percent}
      className={cn("relative isolate w-full overflow-hidden rounded-full bg-input", TONES[tone], className)}
      ref={ref}
      role="progressbar"
      style={{ height: h }}
    >
      {width > 0 && !indeterminate && (
        <>
          <svg aria-hidden className="absolute inset-0 size-full overflow-visible" preserveAspectRatio="none">
            <motion.path d={frontPath} fill="var(--liquid)" />
          </svg>
          {showValue && size === "lg" && (
            <>
              <span className="absolute inset-0 flex items-center justify-center font-medium text-muted-foreground text-xs tabular-nums">
                {percent}%
              </span>
              <motion.span
                aria-hidden
                className="absolute inset-0 flex items-center justify-center font-medium text-(--liquid-ink) text-xs tabular-nums"
                style={{ clipPath: inkClip }}
              >
                {percent}%
              </motion.span>
            </>
          )}
        </>
      )}
      {width > 0 && indeterminate && (
        <>
          <svg aria-hidden className="absolute size-0">
            <filter colorInterpolationFilters="sRGB" height="300%" id={filterId} width="120%" x="-10%" y="-100%">
              <feGaussianBlur in="SourceGraphic" result="blur" stdDeviation={Math.max(h / 4, 1.5)} />
              <feColorMatrix in="blur" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -9" />
            </filter>
          </svg>
          <span className="absolute inset-0" style={{ filter: `url(#${filterId})` }}>
            <motion.span className="absolute inset-y-0 left-0 rounded-full bg-(--liquid)" style={{ x: slugX, width: slugW }} />
            <motion.span
              className="absolute top-1/2 left-0 rounded-full bg-(--liquid)"
              style={{ x: dropX, width: h, height: h, marginTop: -h / 2 }}
            />
          </span>
          <motion.span
            className={cn("absolute inset-y-0 left-0 rounded-full bg-(--liquid)", reduce && "animate-pulse")}
            style={{ x: slugX, width: slugW }}
          />
        </>
      )}
    </div>
  );
}
```
