How to build an animated liquid fill gauge with SVG SVG で作る水が満ちる液体ゲージ
Show progress, capacity or scores as water rising inside a circle, with a surface that reacts to clicks. 進捗・容量・スコアを円の中で満ちる水として表示し、クリックにも反応します。
ライブデモ Live Demo
概要・用途・特徴Overview, Usage & Features
何ができるかWhat it does
A circular gauge in which two wave paths, clipped to a circle, rise to the value being shown. The level eases toward the target and a click sloshes the surface.
円形にクリップした2枚の波パスが、表示する値まで満ちていくゲージです。水位は目標値へイージングで追従し、クリックすると水面が揺れます。
どこで使うかWhere to use
Use it for progress, storage usage, quota and score displays on dashboards or landing pages, and for water-related brands that want the metaphor built into the UI.
ダッシュボードやランディングページでの進捗・ストレージ使用量・クォータ・スコア表示に向きます。水まわりの業種では、比喩そのものをUIにできます。
特徴Key features
Two counter-moving wave layers, eased level changes, a click slosh that decays, automatic wave damping near empty and full, a live aria-label, and a loop that pauses offscreen or in a hidden tab.
逆向きに動く2層の波、イージングする水位変化、減衰するクリック時の揺れ、空・満水付近での自動的な波の抑制、現在値を反映する aria-label、画面外・非表示タブでのループ停止に対応します。
調整可能パラメータ Adjustable Parameters
| Parameter | Default | Description |
|---|---|---|
target | 0.72 | Value to display (0–1). The level eases toward it. |
height | 7 | Wave height in viewBox units; the back wave uses 0.8×. |
level easing | dt·3 | How quickly the level follows the target. |
STEP | 5 | Horizontal sample spacing. Smaller is smoother but heavier. |
fill / stroke | #06b6d4 | Colours of the water, well and ring. |
実装コード Implementation Code
<!-- (1) Circular gauge: two wave paths clipped to a circle -->
<div class="liquid-gauge" id="liquid_gauge" role="img" aria-label="Progress 72%">
<svg viewBox="0 0 200 200" aria-hidden="true" focusable="false">
<defs>
<clipPath id="liquid_clip"><circle cx="100" cy="100" r="92"></circle></clipPath>
</defs>
<circle class="liquid-well" cx="100" cy="100" r="92"></circle>
<g clip-path="url(#liquid_clip)">
<path class="liquid-back" id="liquid_back"></path>
<path class="liquid-front" id="liquid_front"></path>
</g>
<circle class="liquid-ring" cx="100" cy="100" r="95"></circle>
<text class="liquid-value" id="liquid_value" x="100" y="114" text-anchor="middle">72%</text>
</svg>
</div>
<script>
(function () {
const R = 92, CX = 100, CY = 100; // gauge circle (viewBox units)
const STEP = 5; // horizontal sample spacing
let target = 0.72; // 0..1, the value to display
let height = 7; // wave height
const gauge = document.getElementById('liquid_gauge');
const back = document.getElementById('liquid_back');
const front = document.getElementById('liquid_front');
const label = document.getElementById('liquid_value');
let level = 0, t = 0, last = 0, rafId = null, running = false;
let slosh = 0; // tilt impulse set by a click
// (2) Surface path for one layer at the current level
function surface(speed, phase, k, amp) {
const base = CY + R - level * 2 * R;
// Waves flatten near empty / full so the surface never pokes out of the circle
const calm = Math.min(1, 0.2 + 4 * level * (1 - level));
let d = 'M0,200';
for (let x = 0; x <= 200; x += STEP) {
const tilt = (x - CX) * slosh * Math.sin(t * 9) * 0.35;
const y = base + Math.sin(x * k + t * speed + phase) * amp * calm + tilt;
d += ' L' + x + ',' + y.toFixed(1);
}
return d + ' L200,200 Z';
}
function render() {
back.setAttribute('d', surface(-1.3, 1.8, 0.045, height * 0.8));
front.setAttribute('d', surface(1.8, 0, 0.035, height));
const pct = Math.round(level * 100);
label.textContent = pct + '%';
gauge.setAttribute('aria-label', 'Progress ' + pct + '%');
}
function frame(now) {
rafId = requestAnimationFrame(frame);
const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
t += dt;
level += (target - level) * Math.min(1, dt * 3); // ease toward the target
slosh *= Math.exp(-dt * 1.8); // tilt dies away
render();
}
function start() { if (!running) { running = true; last = 0; rafId = requestAnimationFrame(frame); } }
function stop() { if (running) { running = false; cancelAnimationFrame(rafId); } }
// (3) Click or tap to slosh the water
gauge.addEventListener('pointerdown', () => { slosh = 1; });
// (4) Change the value from your app: setLevel(0.4)
window.setLevel = (v) => { target = Math.max(0, Math.min(1, v)); };
// (5) Reduced motion: jump to the value and draw one still frame
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
level = target; render(); return;
}
document.addEventListener('visibilitychange', () => { document.hidden ? stop() : start(); });
if ('IntersectionObserver' in window) {
new IntersectionObserver((e) => { e[0].isIntersecting ? start() : stop(); }).observe(gauge);
} else { start(); }
render();
})();
</script>
.liquid-gauge {
width: min(100%, 260px);
aspect-ratio: 1;
margin: 0 auto;
cursor: pointer;
touch-action: manipulation;
}
.liquid-gauge svg { width: 100%; height: 100%; display: block; }
.liquid-well { fill: #0b2f45; }
.liquid-back { fill: rgba(34, 211, 238, 0.45); }
.liquid-front { fill: #06b6d4; }
.liquid-ring { fill: none; stroke: #67e8f9; stroke-width: 4; }
.liquid-value {
font: 700 44px/1 system-ui, sans-serif;
fill: #fff;
paint-order: stroke;
stroke: rgba(8, 47, 73, 0.55); /* keeps the number legible on water and on air */
stroke-width: 4px;
pointer-events: none;
}
import { useEffect, useRef, useState } from "react";
import "./M-018.css";
const R = 92;
const CX = 100;
const CY = 100;
const STEP = 5;
export default function LiquidGauge({ value = 0.72, height = 7 }) {
const gaugeRef = useRef(null);
const backRef = useRef(null);
const frontRef = useRef(null);
const [percent, setPercent] = useState(Math.round(value * 100));
// Live values, so prop changes don't restart the loop
const live = useRef({ value, height });
live.current = { value, height };
useEffect(() => {
const gauge = gaugeRef.current;
const back = backRef.current;
const front = frontRef.current;
if (!gauge || !back || !front) return undefined;
let level = 0, t = 0, last = 0, rafId = null, running = false, slosh = 0, shown = -1;
function surface(speed, phase, k, amp) {
const base = CY + R - level * 2 * R;
const calm = Math.min(1, 0.2 + 4 * level * (1 - level));
let d = "M0,200";
for (let x = 0; x <= 200; x += STEP) {
const tilt = (x - CX) * slosh * Math.sin(t * 9) * 0.35;
const y = base + Math.sin(x * k + t * speed + phase) * amp * calm + tilt;
d += ` L${x},${y.toFixed(1)}`;
}
return `${d} L200,200 Z`;
}
function render() {
const h = live.current.height;
back.setAttribute("d", surface(-1.3, 1.8, 0.045, h * 0.8));
front.setAttribute("d", surface(1.8, 0, 0.035, h));
const pct = Math.round(level * 100);
if (pct !== shown) {
shown = pct;
setPercent(pct);
}
}
function frame(now) {
rafId = requestAnimationFrame(frame);
const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
t += dt;
const target = Math.max(0, Math.min(1, live.current.value));
level += (target - level) * Math.min(1, dt * 3);
slosh *= Math.exp(-dt * 1.8);
render();
}
const start = () => {
if (!running) {
running = true;
last = 0;
rafId = requestAnimationFrame(frame);
}
};
const stop = () => {
if (running) {
running = false;
cancelAnimationFrame(rafId);
}
};
const onDown = () => {
slosh = 1;
};
const onVisibility = () => (document.hidden ? stop() : start());
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
level = Math.max(0, Math.min(1, live.current.value));
render();
return undefined;
}
gauge.addEventListener("pointerdown", onDown);
document.addEventListener("visibilitychange", onVisibility);
const io = new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop()));
io.observe(gauge);
render();
return () => {
stop();
io.disconnect();
gauge.removeEventListener("pointerdown", onDown);
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
return (
<div className="liquid-gauge" ref={gaugeRef} role="img" aria-label={`Progress ${percent}%`}>
<svg viewBox="0 0 200 200" aria-hidden="true" focusable="false">
<defs>
<clipPath id="liquid_clip">
<circle cx="100" cy="100" r="92" />
</clipPath>
</defs>
<circle className="liquid-well" cx="100" cy="100" r="92" />
<g clipPath="url(#liquid_clip)">
<path className="liquid-back" ref={backRef} />
<path className="liquid-front" ref={frontRef} />
</g>
<circle className="liquid-ring" cx="100" cy="100" r="95" />
<text className="liquid-value" x="100" y="114" textAnchor="middle">
{percent}%
</text>
</svg>
</div>
);
}
.liquid-gauge {
width: min(100%, 260px);
aspect-ratio: 1;
margin: 0 auto;
cursor: pointer;
touch-action: manipulation;
}
.liquid-gauge svg { width: 100%; height: 100%; display: block; }
.liquid-well { fill: #0b2f45; }
.liquid-back { fill: rgba(34, 211, 238, 0.45); }
.liquid-front { fill: #06b6d4; }
.liquid-ring { fill: none; stroke: #67e8f9; stroke-width: 4; }
.liquid-value {
font: 700 44px/1 system-ui, sans-serif;
fill: #fff;
paint-order: stroke;
stroke: rgba(8, 47, 73, 0.55); /* keeps the number legible on water and on air */
stroke-width: 4px;
pointer-events: none;
}
仕組みとカスタマイズHow It Works & Customization
仕組みHow it works
A clipPath circle (r=92) masks two paths. Each path samples y = base + sin(x·k + t·speed + phase)·amp every 5 units and closes to the bottom. base = CY + R − level·2R, and level += (target − level)·min(1, dt·3). The amplitude is multiplied by calm = min(1, 0.2 + 4·level·(1−level)). A click sets slosh = 1, which adds (x − centre)·slosh·sin(t·9) as a tilt and decays exponentially.
clipPath の円(r=92)で2本のパスを切り抜きます。各パスは y = base + sin(x·k + t·speed + phase)·amp を5単位ごとにサンプリングし、底辺まで閉じます。base = CY + R − level·2R、level は level += (target − level)·min(1, dt·3) で目標に近づけます。振幅には calm = min(1, 0.2 + 4·level·(1−level)) を掛けます。クリックで slosh = 1 とし、(x − 中心)·slosh·sin(t·9) の傾きを加えて指数減衰させます。
カスタマイズ方法Customization
Call window.setLevel() or change target to set the value. Adjust height for wave size, the dt·3 factor for how quickly the level follows, and the CSS fills for colour. Switch the water colour by level to signal low capacity.
window.setLevel() か target を変えて値を設定します。height で波の大きさ、dt·3 の係数で水位の追従速度、CSS の fill で色を調整できます。水位に応じて水の色を切り替えれば、残量の警告にも使えます。
注意点Caveats
The clipPath id must be unique on the page. The number overlaps both water and background, so keep its outline. The waves are decorative: give the wrapper role="img" and an aria-label with the current value, and draw one still frame under prefers-reduced-motion.
clipPath の id はページ内で一意にしてください。数値は水と背景の両方に重なるため、縁取りを残します。波は装飾なので、ラッパーに role="img" と現在値入りの aria-label を付け、prefers-reduced-motion では静止した1フレームを描画してください。
よくある質問 Frequently Asked Questions
How do I change the value from my app? アプリから値を変えるには?
Call window.setLevel(0.4) in the standalone code, or pass a new value prop in React. The level eases toward it, so you can call it as often as you like.
標準コードでは window.setLevel(0.4) を呼び、React では value プロップを更新します。水位はイージングで追従するので、頻繁に呼んでも問題ありません。
Why is the number outlined? 数値に縁取りがあるのはなぜですか?
The number sits across the water surface, so part of it is on water and part on the dark background. A dark outline keeps it legible at every level.
数値は水面をまたいで表示されるため、水の上と背景の上の両方に乗ります。濃い縁取りでどの水位でも読めるようにしています。
Can I show several gauges on one page? 1ページに複数のゲージを置けますか?
Yes. Give each clipPath a unique id and each gauge its own element ids, then initialise them separately.
可能です。clipPath の id と各ゲージの要素 id をそれぞれ一意にして、個別に初期化してください。
Is it heavy on the CPU? 負荷は高いですか?
Each frame samples about 41 points for each of two paths and makes two setAttribute calls. The loop stops while offscreen or in a hidden tab.
1フレームあたり2本のパスで各約41点を計算し、setAttribute を2回呼ぶだけです。画面外や非表示タブではループを止めます。
AIへの指示テンプレート AI Prompt Template
以下をAIに貼り付けると実装を依頼できます。 Paste the following into your AI assistant to request implementation.