// src/components/PourLoader/PourLoader.tsx
import { motion, useReducedMotion } from "framer-motion";
import { useId } from "react";
type PourLoaderProps = {
className?: string;
/** Width in px — height is always 4/3 of the width */
size?: number;
/** Drink color — use your brand or flavor color */
color?: string;
/** Seconds for one fill-and-empty cycle */
speed?: number;
/**
* Optional 0–100. When set, the glass fills to that level and stays there
* (a real progress bar) instead of looping.
*/
progress?: number;
/** Show rising fizz bubbles */
bubbles?: boolean;
label?: string;
};
// viewBox is 120 x 160
// Glass outline (open at the top) and its inside, used to clip the drink
const GLASS = "M 20 20 L 30 140 Q 31 148 39 148 H 81 Q 89 148 90 140 L 100 20";
const GLASS_INSIDE =
"M 24 22 L 33.4 139 Q 34 144.5 40 144.5 H 80 Q 86 144.5 86.6 139 L 96 22 Z";
// Liquid level positions (y of the surface)
const LEVEL_EMPTY = 149;
const LEVEL_FULL = 36;
const WAVE_AMP = 3;
const FRONT_WAVELENGTH = 40;
const BACK_WAVELENGTH = 52;
// x position, radius, seconds to rise, start delay
const BUBBLES = [
[44, 1.6, 2.6, 0],
[52, 1.1, 2.1, 0.9],
[60, 2.1, 3.0, 0.4],
[67, 1.3, 2.3, 1.5],
[74, 1.8, 2.8, 0.7],
[56, 1.0, 1.9, 1.9],
[79, 1.2, 2.4, 2.2],
] as const;
/**
* A repeating sine-like wave, drawn with its resting surface at y = 0
* and filled down to y = 200. It's wider than the glass so it can slide
* sideways by one wavelength and loop seamlessly.
*/
function wavePath(wavelength: number) {
const start = -wavelength * 2;
const end = 120 + wavelength * 2;
let d = `M ${start} 0`;
for (let x = start; x < end; x += wavelength) {
d +=
` Q ${x + wavelength / 4} ${-WAVE_AMP} ${x + wavelength / 2} 0` +
` Q ${x + (wavelength * 3) / 4} ${WAVE_AMP} ${x + wavelength} 0`;
}
return `${d} L ${end} 200 L ${start} 200 Z`;
}
const FRONT_WAVE = wavePath(FRONT_WAVELENGTH);
const BACK_WAVE = wavePath(BACK_WAVELENGTH);
export function PourLoader({
className = "",
size = 120,
color = "#F97316",
speed = 4,
progress,
bubbles = true,
label = "Loading",
}: PourLoaderProps) {
const uid = useId().replace(/:/g, "");
const reduce = useReducedMotion();
const isProgress = typeof progress === "number";
const clamped = isProgress ? Math.min(100, Math.max(0, progress)) : 65;
const targetLevel =
LEVEL_EMPTY - (clamped / 100) * (LEVEL_EMPTY - LEVEL_FULL);
// How the liquid surface moves:
// progress mode → glide to the target level
// loop mode → fill up, hold, then drain
// reduced motion → sit still
const fill =
reduce || isProgress
? {
animate: { y: targetLevel },
transition: reduce
? { duration: 0 }
: { type: "spring" as const, stiffness: 60, damping: 16 },
}
: {
animate: { y: [LEVEL_EMPTY, LEVEL_FULL, LEVEL_FULL, LEVEL_EMPTY] },
transition: {
duration: speed,
times: [0, 0.55, 0.75, 1],
ease: "easeInOut" as const,
repeat: Infinity,
},
};
const startLevel = isProgress || reduce ? targetLevel : LEVEL_EMPTY;
// The bubble clip sits just below the wave crests
const surfaceY = (level: number | number[]) =>
Array.isArray(level) ? level.map((l) => l + WAVE_AMP) : level + WAVE_AMP;
const a11y = isProgress
? {
role: "progressbar",
"aria-valuemin": 0,
"aria-valuemax": 100,
"aria-valuenow": Math.round(clamped),
"aria-label": label || "Loading",
}
: { role: "status", "aria-label": label || "Loading" };
return (
<div className={`flex flex-col items-center gap-4 ${className}`} {...a11y}>
<div
className="relative aspect-[3/4]"
style={{ width: size, maxWidth: "100%" }}
>
<svg
viewBox="0 0 120 160"
className="h-full w-full overflow-visible"
fill="none"
aria-hidden="true"
>
<defs>
<clipPath id={`glass-${uid}`}>
<path d={GLASS_INSIDE} />
</clipPath>
{/* Moves with the liquid, so bubbles only show under the surface */}
<clipPath id={`liquid-${uid}`}>
{/* Animates the y attribute (not a transform), since some
browsers ignore CSS transforms inside a clipPath */}
<motion.rect
x={0}
width={120}
height={200}
initial={{ attrY: surfaceY(startLevel) }}
animate={{ attrY: surfaceY(fill.animate.y) }}
transition={fill.transition}
/>
</clipPath>
{/* Slightly darker toward the bottom for depth */}
<linearGradient id={`depth-${uid}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#000" stopOpacity={0} />
<stop offset="100%" stopColor="#000" stopOpacity={0.28} />
</linearGradient>
</defs>
{/* Glass body */}
<path d={`${GLASS} Z`} fill="currentColor" fillOpacity={0.04} />
<g clipPath={`url(#glass-${uid})`}>
{/* Drink */}
<motion.g initial={{ y: startLevel }} {...fill}>
{/* Back wave — lighter, slower, moving the other way */}
<motion.path
d={BACK_WAVE}
fill={color}
fillOpacity={0.5}
initial={{ x: -BACK_WAVELENGTH }}
animate={
reduce
? { x: -BACK_WAVELENGTH }
: { x: [-BACK_WAVELENGTH, 0] }
}
transition={
reduce
? { duration: 0 }
: { duration: 2.4, ease: "linear", repeat: Infinity }
}
style={{ translateY: -2 }}
/>
{/* Front wave, with a bright surface line */}
<motion.path
d={FRONT_WAVE}
fill={color}
stroke="#fff"
strokeOpacity={0.35}
strokeWidth={1.2}
initial={{ x: 0 }}
animate={reduce ? { x: 0 } : { x: [0, -FRONT_WAVELENGTH] }}
transition={
reduce
? { duration: 0 }
: { duration: 1.6, ease: "linear", repeat: Infinity }
}
/>
</motion.g>
{/* Depth shading over the drink */}
<rect
x={0}
y={20}
width={120}
height={130}
fill={`url(#depth-${uid})`}
/>
{/* Fizz bubbles, only visible inside the drink */}
{bubbles && !reduce && (
<g clipPath={`url(#liquid-${uid})`}>
{BUBBLES.map(([x, r, duration, delay], i) => (
<motion.circle
key={i}
r={r}
fill="#fff"
fillOpacity={0.55}
initial={{ cx: x, cy: 146, opacity: 0 }}
animate={{
cy: [146, 30],
cx: [x, x + 2, x - 1.5, x + 1, x],
opacity: [0, 1, 1],
}}
transition={{
duration,
delay,
ease: "easeIn",
repeat: Infinity,
}}
/>
))}
</g>
)}
</g>
{/* Glass outline and shine */}
<path
d={GLASS}
stroke="currentColor"
strokeOpacity={0.4}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M 30 32 L 36.5 116"
stroke="#fff"
strokeOpacity={0.3}
strokeWidth={3}
strokeLinecap="round"
/>
<path
d="M 36.8 124 L 37.3 130"
stroke="#fff"
strokeOpacity={0.3}
strokeWidth={3}
strokeLinecap="round"
/>
</svg>
</div>
{label && (
<motion.span
aria-hidden="true"
className="text-sm font-medium tracking-[0.2em] text-neutral-400 uppercase"
animate={reduce || isProgress ? {} : { opacity: [0.4, 1, 0.4] }}
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
>
{isProgress ? `${label} ${Math.round(clamped)}%` : label}
</motion.span>
)}
</div>
);
}
// Component Source: https://codekk.devComponent details
Overview, install command, dependencies, and project links.
A liquid fill loader for React built with TypeScript, Framer Motion, SVG, and Tailwind CSS, designed for beverage brands, juice bars, coffee shops, breweries, and soda websites. A glass fills with a fizzy drink, holds for a moment, then drains and refills in a smooth loop. The surface is made of two wave layers that slide in opposite directions at different speeds, so the liquid looks like it's really moving rather than rising as a flat block. Each wave is wider than the glass and shifts by exactly one wavelength per loop, so the motion never jumps or resets. The front wave has a thin bright surface line, and a subtle gradient darkens the drink toward the bottom for depth. Fizz bubbles wobble their way up and disappear at the surface. They're clipped to the liquid itself, so they never float in the empty part of the glass, and the clip animates an SVG attribute rather than a CSS transform for reliable cross-browser rendering. A glass shine completes the look. The color prop sets the drink to any brand or flavor color, and bubbles={false} turns the fizz off for still drinks like juice, coffee, or smoothies. The optional progress prop turns the loader into a real progress indicator: the glass fills smoothly to the given level with spring motion and stays there, the label shows the percentage, and the wrapper switches to role="progressbar" with proper ARIA values. The glass outline uses currentColor, so it adapts to light or dark themes. Clip and gradient IDs come from React's useId, so multiple loaders can share a page without conflicts. When prefers-reduced-motion is enabled, the drink sits still at 65% full, or at the progress level if one is set. It works in Vite, Create React App, and Next.js.
Discussion
Feedback, implementation tips, and usage notes.
Comments
Sign in to join the conversation
Similar Components
More from the same category




