A hydration widget for a health or habit app. The left side shows today's total in large light numerals, a line saying how many glasses are left, a row of eight small glass markers and two buttons: one adds 250 ml and a small one takes it back. The right side is a tall gauge where a column of blue water with two drifting wave lines and a few rising bubbles climbs to match the total. Pressing the add button counts the number up, fills the next marker and lifts the water. At 2,000 ml the button locks and the line changes to a goal message. The card is a fixed comfortable width and fits a 360 pixel phone.
// Adapted from Animata (https://animata.design/docs/widget/water-tracker)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { animate, useReducedMotion } from "framer-motion";
const DAILY_GOAL = 2000; // ml
const SIP = 250; // ml added per press
const START = 750;
export default function WaterIntakeTrackerWidgetWithRisingWaveGauge() {
const reduce = useReducedMotion();
const [intake, setIntake] = useState(START);
const numberRef = useRef<HTMLSpanElement>(null);
const shown = useRef(START);
const atGoal = intake >= DAILY_GOAL;
const progress = Math.min(1, intake / DAILY_GOAL);
const glasses = Math.round(DAILY_GOAL / SIP);
const filled = Math.floor(intake / SIP);
// The big number counts to its new value instead of jumping.
useEffect(() => {
const node = numberRef.current;
if (!node) return;
if (reduce) {
node.textContent = intake.toLocaleString("en-GB");
shown.current = intake;
return;
}
const controls = animate(shown.current, intake, {
duration: 0.6,
ease: [0.2, 0.8, 0.2, 1],
onUpdate: (value) => {
shown.current = value;
node.textContent = Math.round(value).toLocaleString("en-GB");
},
});
return () => controls.stop();
}, [intake, reduce]);
return (
<div className="bg-sky-100 px-4 py-14 sm:py-20">
<style>{`@keyframes water-intake-wave { from { transform: translateX(0); } to { transform: translateX(-50%); } }
@keyframes water-intake-bubble { 0% { transform: translateY(0) scale(0.6); opacity: 0; } 20% { opacity: 0.7; } 100% { transform: translateY(-150px) scale(1); opacity: 0; } }
.water-intake-wave { animation: water-intake-wave 5s linear infinite; }
.water-intake-wave-slow { animation: water-intake-wave 8s linear infinite reverse; }
.water-intake-bubble { animation: water-intake-bubble 3.4s ease-in infinite; }
@media (prefers-reduced-motion: reduce) { .water-intake-wave, .water-intake-wave-slow, .water-intake-bubble { animation: none; } .water-intake-fill { transition: none !important; } }`}</style>
<div className="mx-auto flex w-full max-w-sm overflow-hidden rounded-[2rem] bg-white shadow-xl shadow-sky-300/50 ring-1 ring-sky-200">
<div className="flex min-w-0 flex-1 flex-col p-6">
<div className="flex items-center gap-2">
<svg viewBox="0 0 20 20" className="h-5 w-5 text-sky-600" fill="currentColor" aria-hidden="true">
<path d="M10 2s5.5 5.6 5.5 10a5.5 5.5 0 0 1-11 0C4.5 7.6 10 2 10 2Z" />
</svg>
<h2 className="text-base font-semibold text-slate-900">Water</h2>
</div>
<p className="mt-5 text-5xl font-light tabular-nums tracking-tight text-slate-900">
<span ref={numberRef}>{START.toLocaleString("en-GB")}</span>
<span className="ml-1 text-lg font-medium text-slate-600">ml</span>
</p>
<p className="mt-1 text-sm text-slate-600" aria-live="polite">
{atGoal ? "Goal reached. Nicely done." : `of ${DAILY_GOAL.toLocaleString("en-GB")} ml today, ${glasses - filled} glasses to go`}
</p>
<ul className="mt-5 flex gap-1.5" aria-hidden="true">
{Array.from({ length: glasses }, (_, index) => (
<li
key={index}
className={`h-6 flex-1 rounded-b-md rounded-t-sm border-2 transition-colors duration-300 ${index < filled ? "border-sky-600 bg-sky-500" : "border-slate-300 bg-white"}`}
/>
))}
</ul>
<div className="mt-6 flex gap-2">
<button
type="button"
onClick={() => setIntake((current) => Math.min(DAILY_GOAL, current + SIP))}
disabled={atGoal}
className="h-12 flex-1 rounded-full bg-sky-600 text-base font-semibold text-white transition hover:bg-sky-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-600 focus-visible:ring-offset-2 active:scale-95 disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-600 motion-reduce:active:scale-100"
>
{atGoal ? "Done for today" : `+${SIP} ml`}
</button>
<button
type="button"
aria-label={`Remove ${SIP} ml`}
onClick={() => setIntake((current) => Math.max(0, current - SIP))}
disabled={intake === 0}
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border border-slate-300 text-slate-700 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-600 focus-visible:ring-offset-2 disabled:opacity-40"
>
<svg viewBox="0 0 20 20" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M5 10h10" />
</svg>
</button>
</div>
</div>
{/* The gauge: a full-height column of water that slides up from below, with two wave lines on top. */}
<div
role="progressbar"
aria-label="Water drunk today"
aria-valuemin={0}
aria-valuemax={DAILY_GOAL}
aria-valuenow={intake}
className="relative w-20 shrink-0 overflow-hidden border-l border-sky-200 bg-sky-50"
>
<div
className="water-intake-fill absolute inset-0 transition-transform duration-700 ease-[cubic-bezier(0.2,0.8,0.2,1)]"
style={{ transform: `translateY(${(1 - progress) * 100}%)` }}
>
<svg viewBox="0 0 320 20" preserveAspectRatio="none" className="water-intake-wave-slow absolute -top-3 left-0 h-4 w-[400%] text-sky-300" fill="currentColor" aria-hidden="true">
<path d="M0 10 Q20 0 40 10 T80 10 T120 10 T160 10 T200 10 T240 10 T280 10 T320 10 V20 H0Z" />
</svg>
<svg viewBox="0 0 320 20" preserveAspectRatio="none" className="water-intake-wave absolute -top-2 left-0 h-4 w-[400%] text-sky-500" fill="currentColor" aria-hidden="true">
<path d="M0 10 Q20 0 40 10 T80 10 T120 10 T160 10 T200 10 T240 10 T280 10 T320 10 V20 H0Z" />
</svg>
<div className="absolute inset-x-0 bottom-0 top-1 bg-gradient-to-b from-sky-500 to-sky-700" />
{[18, 44, 62].map((left, index) => (
<span
key={left}
aria-hidden="true"
className="water-intake-bubble absolute bottom-2 h-2 w-2 rounded-full bg-white/70"
style={{ left: `${left}%`, animationDelay: `${index * 1.1}s` }}
/>
))}
</div>
<ul className="absolute inset-y-0 right-0 flex flex-col justify-between py-3" aria-hidden="true">
{[100, 75, 50, 25, 0].map((mark) => (
<li key={mark} className="flex items-center justify-end gap-1 text-[10px] font-semibold text-slate-900 mix-blend-luminosity">
{mark}
<span className="h-px w-2 bg-slate-900" />
</li>
))}
</ul>
</div>
</div>
</div>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/water-intake-tracker-widget-with-rising-wave-gauge.json
The water is a block the full height of the gauge. It is pushed down with `translateY` by the unfilled percentage, and the gauge clips whatever falls outside. Changing the percentage slides the block up, which is cheaper for the browser than animating height.
Each wave is an SVG four times the width of the gauge with a repeating curve. A keyframe slides it left by half its own width and loops. Because the curve repeats exactly, the start and end frames look identical and the loop is seamless.
Lift `intake` out of the component and store it wherever your app keeps user data, keyed by date so it resets each day. The component only needs the current number and a way to change it.
Yes. Set `SIP` to 1 and `DAILY_GOAL` to 8, and change the `ml` label to `glasses`. Everything else is calculated from those two numbers.
A short count draws the eye to what changed. framer-motion's `animate` function writes each value straight into the span, so the count does not re-render the component on every frame.
Adapted from Water Tracker in Animata, used under its open-source licence. Licences and credits
Related: Activity Rings Card with Day Switcher, Habit Tracker Phone Screen with Tab Bar, Stats Section with Radial Progress Rings.
More app and game ui or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.