Loaders
TeaGlassFill
A steel kettle pours a long thin stream into a fluted cutting-chai glass. It fills, foams and steams, then slides off the counter and an empty glass slides in.
Download
- React
- Tailwind CSS v4
- motion
components/ui/tea-glass-fill.tsx
"use client";
import { useEffect, useId, useState } from "react";
import { useReducedMotion } from "motion/react";
type TeaGlassFillProps = {
/** Colour of the tea. */
tea?: string;
/** Colour of the foam on top. */
foam?: string;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
/** Read out by screen readers. */
label?: string;
className?: string;
};
const clamp = (v: number) => Math.min(1, Math.max(0, v));
const span = (p: number, a: number, b: number) => clamp((p - a) / (b - a));
const ease = (t: number) => t * t * (3 - 2 * t);
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const BASE = 204; // Inside floor of the glass.
const FULL = 0.86;
const TILT = 24; // Kettle tilt in degrees at full pour.
const REST = 0.42; // The frame shown before the clock starts and for reduced motion.
const FLUTES = [-3, -2, -1, 0, 1, 2, 3];
const BUBBLES = [
[-0.62, 1.6],
[-0.3, 2.2],
[0.05, 1.5],
[0.38, 2],
[0.68, 1.4],
];
/* TeaGlassFill: a steel kettle pours a long thin stream into a fluted cutting-chai glass. It fills, foams, steams, then slides off the counter and an empty glass slides in. */
export function TeaGlassFill({ tea = "#a9622b", foam = "#ecd2a6", speed = 1, label = "Loading", className = "" }: TeaGlassFillProps) {
const reduce = useReducedMotion();
const id = useId().replace(/:/g, "");
const [clock, setClock] = useState(REST);
useEffect(() => {
if (reduce) return;
const seconds = 4.6 / speed;
const start = performance.now() - REST * seconds * 1000;
let raf = 0;
const tick = (now: number) => {
setClock(((now - start) / (seconds * 1000)) % 1);
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [reduce, speed]);
const p = reduce ? REST : clock;
const tilt = ease(span(p, 0, 0.07)) * (1 - ease(span(p, 0.66, 0.74)));
const fill = p >= 0.92 ? 0 : 1 - (1 - span(p, 0.07, 0.68)) ** 1.3;
const slide = -214 * ease(span(p, 0.84, 0.92)) + 214 * (1 - ease(span(p, 0.92, 1))) * Number(p >= 0.92);
const surface = BASE - fill * FULL * 92;
const half = lerp(27, 38, (BASE - surface) / 92);
const foamHigh = 2 + span(fill, 0.1, 0.7) * 5;
// Where the spout tip sits at this tilt; the kettle turns round (178, 66).
const turn = (-TILT * tilt * Math.PI) / 180;
const tipX = 178 + -58 * Math.cos(turn) + 14 * Math.sin(turn);
const tipY = 66 + -58 * Math.sin(turn) - 14 * Math.cos(turn);
const streamOn = span(p, 0.06, 0.09) > 0 && p < 0.74;
const reach = lerp(tipY, surface, ease(span(p, 0.06, 0.1)));
const tail = lerp(tipY, surface, span(p, 0.66, 0.73));
const sway = Math.sin(p * 90) * 1.6;
const steam = span(p, 0.58, 0.72) * (1 - span(p, 0.84, 0.88));
return (
<div role="status" aria-label={label} className={`w-64 ${className}`}>
<svg viewBox="0 0 240 232" className="block w-full overflow-hidden" aria-hidden>
<defs>
<linearGradient id={`${id}s`} x1="0" x2="1">
<stop offset="0" stopColor="#7d858c" />
<stop offset="0.3" stopColor="#f2f5f7" />
<stop offset="0.62" stopColor="#aab2b9" />
<stop offset="1" stopColor="#5d646b" />
</linearGradient>
<linearGradient id={`${id}t`} x1="0" x2="1">
<stop offset="0" stopColor={tea} stopOpacity="0.82" />
<stop offset="0.45" stopColor={tea} />
<stop offset="1" stopColor="#000" stopOpacity="0.45" />
</linearGradient>
<linearGradient id={`${id}w`} x1="0" y1="0" y2="1" x2="0">
<stop offset="0" stopColor="#6b4426" />
<stop offset="1" stopColor="#38210f" />
</linearGradient>
<linearGradient id={`${id}f`} x1="0" x2="1">
<stop offset="0" stopColor="#fff" stopOpacity="0" />
<stop offset="0.12" stopColor="#fff" />
<stop offset="0.88" stopColor="#fff" />
<stop offset="1" stopColor="#fff" stopOpacity="0" />
</linearGradient>
<mask id={`${id}m`} maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="232">
<rect width="240" height="232" fill={`url(#${id}f)`} />
</mask>
<clipPath id={`${id}i`}>
<path d="M93 204 L147 204 L158 112 L82 112 Z" />
</clipPath>
</defs>
<g mask={`url(#${id}m)`}>
<rect x="-4" y="212" width="248" height="16" rx="2" fill={`url(#${id}w)`} />
<rect x="-4" y="212" width="248" height="1.6" fill="#fff" opacity="0.16" />
<ellipse cx={120 + slide} cy="213" rx="36" ry="3.4" fill="#000" opacity="0.4" />
{streamOn && (
<path
d={`M${tipX} ${tail} Q${120 + sway} ${(tail + reach) / 2} 120 ${reach}`}
fill="none"
stroke={tea}
strokeWidth="3"
strokeLinecap="round"
/>
)}
<g transform={`translate(${slide} 0)`}>
<g clipPath={`url(#${id}i)`}>
<rect x="70" y={surface} width="100" height={BASE - surface} fill={`url(#${id}t)`} />
{fill > 0 && <ellipse cx="120" cy={surface} rx={half} ry="3.4" fill={tea} />}
{fill > 0.06 && (
<>
<rect x="70" y={surface - foamHigh} width="100" height={foamHigh + 1} fill={foam} />
<ellipse cx="120" cy={surface - foamHigh} rx={half} ry="3.2" fill="#fbf0db" />
{BUBBLES.map(([x, r], i) => (
<circle
key={i}
cx={120 + x * half}
cy={surface - foamHigh - 0.4 + Math.sin(p * 40 + i * 2) * 0.6}
r={r}
fill="#fff"
opacity="0.72"
/>
))}
</>
)}
</g>
{/* The glass: thick base, fluted walls, a band at the rim. */}
<path d="M79 108 L91 208 Q92 211 96 211 L144 211 Q148 211 149 208 L161 108" fill="#fff" fillOpacity="0.07" stroke="#fff" strokeOpacity="0.62" strokeWidth="2" strokeLinejoin="round" />
{FLUTES.map((n) => (
<line
key={n}
x1={120 + n * 10.4}
y1="122"
x2={120 + n * 7.8}
y2="201"
stroke="#fff"
strokeOpacity={n < 0 ? 0.3 : 0.14}
strokeWidth={n === -2 ? 3 : 1.5}
strokeLinecap="round"
/>
))}
<path d="M80 117 Q120 123 160 117" fill="none" stroke="#fff" strokeOpacity="0.28" strokeWidth="1.4" />
<ellipse cx="120" cy="108" rx="41" ry="4.6" fill="none" stroke="#fff" strokeOpacity="0.8" strokeWidth="1.8" />
</g>
{steam > 0 &&
[-16, 2, 18].map((x, i) => {
const rise = ((p * 3 + i * 0.3) % 1) * 22;
return (
<path
key={x}
d={`M${120 + x + slide} ${100 - rise} q-7 -9 0 -18 q7 -9 0 -18`}
fill="none"
stroke="#fff"
strokeWidth="2.4"
strokeLinecap="round"
opacity={steam * 0.34 * (1 - rise / 30)}
/>
);
})}
</g>
{/* Kettle, drawn with its pivot at the origin so the tilt is one rotate. */}
<g transform={`translate(178 66) rotate(${-TILT * tilt})`}>
<path d="M-58 -14 Q-40 -8 -30 8 L-20 26 L-16 -6 Q-30 -14 -52 -19 Z" fill={`url(#${id}s)`} />
<path d="M-22 -30 h44 q10 28 7 56 q-1 9 -11 9 h-36 q-10 0 -11 -9 q-3 -28 7 -56 Z" fill={`url(#${id}s)`} />
<ellipse cx="0" cy="-30" rx="22" ry="4.6" fill="#c9d0d6" />
<path d="M-14 -33 q14 -8 28 0" fill="#8e969d" />
<circle cx="0" cy="-39" r="3.4" fill="#2b2017" />
<path d="M24 -24 q30 -2 26 24 q-3 18 -21 20" fill="none" stroke="#2b2017" strokeWidth="6" strokeLinecap="round" />
<path d="M-25 22 q25 6 50 0" fill="none" stroke="#000" strokeOpacity="0.2" strokeWidth="1.4" />
</g>
</svg>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/tea-glass-fill.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/tea-glass-fill-demo.tsx
import { TeaGlassFill } from "@/components/ui/tea-glass-fill"; export default function TeaGlassFillExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[radial-gradient(circle_at_50%_40%,#3a2616,#160d07_75%)] p-2"> <TeaGlassFill className="w-[min(92cqh,88cqw,300px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| tea | string | "#a9622b" | Colour of the tea. |
| foam | string | "#ecd2a6" | Colour of the foam on top. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| label | string | "Loading" | Read out by screen readers (role=status). |
| className | string | "w-64" | Set the width here. Everything scales with it. |