Stops
- 1Ferry pier
- 2Night market
- 3Lighthouse
Route
4.2 kmdistance
68 minon foot
Night walkHarbour loop
import { useEffect, useRef, useState } from 'react';
const css = `
.card-tank {
position: relative;
width: 230px;
height: 150px;
border-radius: 18px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
overflow: hidden;
touch-action: none;
}
.card-tank canvas { position: absolute; left: 0; top: 0; width: 230px; height: 150px; }
.card-tank__text { position: relative; padding: 14px 16px; pointer-events: none; }
.card-tank__label { color: light-dark(#50535b, #c6c8cd); font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; }
.card-tank__pct { margin-top: 4px; color: light-dark(#373a40, #f1f2f3); font-size: 34px; font-weight: 600; letter-spacing: -0.03em; line-height: 1; font-variant-numeric: tabular-nums; }
.card-tank__used { margin-top: 4px; color: light-dark(#42454c, #e5e6e9); font-size: 11px; font-variant-numeric: tabular-nums; }
.card-tank__btn {
position: absolute;
right: 12px;
top: 12px;
padding: 5px 10px;
border: 0;
border-radius: 999px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font: inherit;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s ease;
}
.card-tank__btn:active { transform: scale(0.94); }
.card-tank__btn:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 2px; }
.card-tank__probe { position: absolute; width: 0; height: 0; overflow: hidden; }
.card-tank__probe i:nth-child(1) { color: light-dark(#dbdde1, #2e3035); }
.card-tank__probe i:nth-child(2) { color: light-dark(#cbced3, #393c41); }
.card-tank__probe i:nth-child(3) { color: light-dark(#50535b, #c6c8cd); }
`;
const W = 230;
const H = 150;
const N = 47; // surface columns
const GB = 64;
const FULL = 0.64;
const CLEAN = 0.38;
const STEP = 1 / 120;
export function TankCard() {
const root = useRef<HTMLDivElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
const probe = useRef<HTMLSpanElement>(null);
const pct = useRef<HTMLDivElement>(null);
const used = useRef<HTMLDivElement>(null);
const [clean, setClean] = useState(false);
const sim = useRef({ levelT: FULL, kick: 0, px: -1, py: -1, vx: 0, vy: 0, hover: false });
useEffect(() => {
const el = canvas.current!;
const dpr = Math.min(2, devicePixelRatio || 1);
el.width = W * dpr;
el.height = H * dpr;
const ctx = el.getContext('2d')!;
ctx.scale(dpr, dpr);
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
const h = new Float32Array(N);
const v = new Float32Array(N);
const s = sim.current;
let level = s.levelT;
let levelV = 0;
let tilt = 0;
let tiltV = 0;
let acc = 0;
let last = performance.now();
let raf = 0;
const bubbles = Array.from({ length: 8 }, (_, i) => ({ x: 20 + ((i * 53) % 190), y: H + i * 18, r: 1.5 + (i % 3), sp: 14 + (i % 4) * 6 }));
const colX = (i: number) => (i / (N - 1)) * W;
const surf = (i: number, t: number, back = 0) =>
H * (1 - level) + tilt * (colX(i) - W / 2) + h[i] * (back ? 0.6 : 1) + (still ? 0 : Math.sin(t * 1.8 + i * 0.35 + back * 2) * (1.2 + back));
const step = (dt: number) => {
// The liquid leans toward the cursor, overshooting like a real slosh.
const tiltT = s.hover ? (s.px / W - 0.5) * 0.28 : 0;
tiltV += ((tiltT - tilt) * 40 - tiltV * 3.2) * dt;
tilt += tiltV * dt;
levelV += ((s.levelT - level) * 14 - levelV * 4.5) * dt;
level += levelV * dt;
for (let i = 0; i < N; i++) {
const l = i > 0 ? h[i - 1] : h[i];
const r = i < N - 1 ? h[i + 1] : h[i];
// Changes in tilt rock the whole body: the near side drops as the far side climbs.
v[i] += ((l + r - 2 * h[i]) * 700 - h[i] * 8 - v[i] * 1.8 - tiltV * (colX(i) - W / 2) * 0.9) * dt;
}
for (let i = 0; i < N; i++) h[i] = Math.max(-40, Math.min(40, h[i] + v[i] * dt));
if (s.kick) {
for (let i = 0; i < N; i++) v[i] += Math.sin(i * 0.9) * s.kick;
s.kick = 0;
}
};
const tick = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const t = now / 1000;
if (still) {
level = s.levelT;
h.fill(0);
} else {
// The cursor stirs the surface where it crosses it.
if (s.hover && s.px >= 0) {
const i = Math.round((s.px / W) * (N - 1));
const near = Math.abs(s.py - surf(i, t)) < 34;
if (near) {
const f = Math.max(-260, Math.min(260, s.vy * 0.5 + Math.abs(s.vx) * 0.12));
for (let k = -2; k <= 2; k++) if (v[i + k] !== undefined) v[i + k] += f * dt * 6 * (1 - Math.abs(k) * 0.3);
}
}
s.vx *= 0.8;
s.vy *= 0.8;
acc += dt;
while (acc >= STEP) {
step(STEP);
acc -= STEP;
}
}
const [back, front, line] = [...probe.current!.children].map((c) => getComputedStyle(c).color);
ctx.clearRect(0, 0, W, H);
const fill = (color: string, b: number) => {
ctx.beginPath();
ctx.moveTo(0, H);
for (let i = 0; i < N; i++) ctx.lineTo(colX(i), surf(i, t, b) - b * 5);
ctx.lineTo(W, H);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
};
fill(back, 1);
fill(front, 0);
ctx.beginPath();
for (let i = 0; i < N; i++) ctx[i ? 'lineTo' : 'moveTo'](colX(i), surf(i, t));
ctx.strokeStyle = line;
ctx.globalAlpha = 0.7;
ctx.lineWidth = 1.5;
ctx.stroke();
// Bubbles rise and pop at the surface.
ctx.lineWidth = 1;
ctx.globalAlpha = 0.5;
for (const b of bubbles) {
if (!still) {
b.y -= b.sp * dt;
b.x += Math.sin(t * 3 + b.r * 7) * 0.15;
}
const top = surf(Math.round((b.x / W) * (N - 1)), t);
if (b.y < top + b.r + 2) {
b.y = H + 6 + (b.r * 13) % 30;
b.x = 12 + ((b.x * 7.3 + 41) % 206);
}
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
const p = Math.max(0, Math.min(1, level));
pct.current!.textContent = `${Math.round(p * 100)}%`;
used.current!.textContent = `${Math.round(p * GB)} of ${GB} GB used`;
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
const move = (e: React.PointerEvent) => {
const r = root.current!.getBoundingClientRect();
const s = sim.current;
const x = e.clientX - r.left;
const y = e.clientY - r.top;
if (s.px >= 0) {
s.vx = (x - s.px) * 60;
s.vy = (y - s.py) * 60;
}
s.px = x;
s.py = y;
s.hover = true;
};
const toggle = () => {
const next = !clean;
setClean(next);
sim.current.levelT = next ? CLEAN : FULL;
sim.current.kick = 70;
};
return (
<>
<style>
{css}
</style>
<div
ref={root}
className="card-tank"
role="group"
aria-label={`Storage: ${Math.round((clean ? CLEAN : FULL) * GB)} of ${GB} GB used`}
onPointerMove={move}
onPointerLeave={() => Object.assign(sim.current, { hover: false, px: -1, py: -1 })}
>
<span ref={probe} className="card-tank__probe" aria-hidden="true">
<i />
<i />
<i />
</span>
<canvas ref={canvas} aria-hidden="true" />
<div className="card-tank__text" aria-hidden="true">
<div className="card-tank__label">Storage</div>
<div ref={pct} className="card-tank__pct">
{Math.round(FULL * 100)}%
</div>
<div ref={used} className="card-tank__used" />
</div>
<button type="button" className="card-tank__btn" onClick={toggle}>
{clean ? 'Undo' : 'Clean up'}
</button>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardTank(el) on the mounted element instead.
<div class="card-tank" role="group" aria-label="Storage: 41 of 64 GB used">
<span class="card-tank__probe" aria-hidden="true">
<i></i>
<i></i>
<i></i>
</span>
<canvas aria-hidden="true"></canvas>
<div class="card-tank__text" aria-hidden="true">
<div class="card-tank__label">Storage</div>
<div class="card-tank__pct">64%</div>
<div class="card-tank__used"></div>
</div>
<button type="button" class="card-tank__btn">Clean up</button>
</div>.card-tank {
position: relative;
width: 230px;
height: 150px;
border-radius: 18px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
overflow: hidden;
touch-action: none;
}
.card-tank canvas { position: absolute; left: 0; top: 0; width: 230px; height: 150px; }
.card-tank__text { position: relative; padding: 14px 16px; pointer-events: none; }
.card-tank__label { color: light-dark(#50535b, #c6c8cd); font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; }
.card-tank__pct { margin-top: 4px; color: light-dark(#373a40, #f1f2f3); font-size: 34px; font-weight: 600; letter-spacing: -0.03em; line-height: 1; font-variant-numeric: tabular-nums; }
.card-tank__used { margin-top: 4px; color: light-dark(#42454c, #e5e6e9); font-size: 11px; font-variant-numeric: tabular-nums; }
.card-tank__btn {
position: absolute;
right: 12px;
top: 12px;
padding: 5px 10px;
border: 0;
border-radius: 999px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font: inherit;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s ease;
}
.card-tank__btn:active { transform: scale(0.94); }
.card-tank__btn:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 2px; }
.card-tank__probe { position: absolute; width: 0; height: 0; overflow: hidden; }
.card-tank__probe i:nth-child(1) { color: light-dark(#dbdde1, #2e3035); }
.card-tank__probe i:nth-child(2) { color: light-dark(#cbced3, #393c41); }
.card-tank__probe i:nth-child(3) { color: light-dark(#50535b, #c6c8cd); }function initCardTank(root) {
var canvas = root.querySelector('canvas');
var probe = root.querySelector('.card-tank__probe');
var pct = root.querySelector('.card-tank__pct');
var used = root.querySelector('.card-tank__used');
var btn = root.querySelector('.card-tank__btn');
if (!canvas || !probe || !pct || !used || !btn) return;
var W = 230;
var H = 150;
var N = 47; // surface columns
var GB = 64;
var FULL = 0.64;
var CLEAN = 0.38;
var STEP = 1 / 120;
var dpr = Math.min(2, devicePixelRatio || 1);
canvas.width = W * dpr;
canvas.height = H * dpr;
var ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var h = new Float32Array(N);
var v = new Float32Array(N);
var s = { levelT: FULL, kick: 0, px: -1, py: -1, vx: 0, vy: 0, hover: false };
var clean = false;
var level = FULL;
var levelV = 0;
var tilt = 0;
var tiltV = 0;
var acc = 0;
var last = performance.now();
var raf = 0;
var bubbles = [];
for (var b = 0; b < 8; b++) bubbles.push({ x: 20 + ((b * 53) % 190), y: H + b * 18, r: 1.5 + (b % 3), sp: 14 + (b % 4) * 6 });
function colX(i) { return (i / (N - 1)) * W; }
function surf(i, t, back) {
return H * (1 - level) + tilt * (colX(i) - W / 2) + h[i] * (back ? 0.6 : 1) + (still ? 0 : Math.sin(t * 1.8 + i * 0.35 + back * 2) * (1.2 + back));
}
function step(dt) {
// The liquid leans toward the cursor, overshooting like a real slosh.
var tiltT = s.hover ? (s.px / W - 0.5) * 0.28 : 0;
tiltV += ((tiltT - tilt) * 40 - tiltV * 3.2) * dt;
tilt += tiltV * dt;
levelV += ((s.levelT - level) * 14 - levelV * 4.5) * dt;
level += levelV * dt;
for (var i = 0; i < N; i++) {
var l = i > 0 ? h[i - 1] : h[i];
var r = i < N - 1 ? h[i + 1] : h[i];
// Changes in tilt rock the whole body: the near side drops as the far side climbs.
v[i] += ((l + r - 2 * h[i]) * 700 - h[i] * 8 - v[i] * 1.8 - tiltV * (colX(i) - W / 2) * 0.9) * dt;
}
for (var k = 0; k < N; k++) h[k] = Math.max(-40, Math.min(40, h[k] + v[k] * dt));
if (s.kick) {
for (var j = 0; j < N; j++) v[j] += Math.sin(j * 0.9) * s.kick;
s.kick = 0;
}
}
function fill(color, back, t) {
ctx.beginPath();
ctx.moveTo(0, H);
for (var i = 0; i < N; i++) ctx.lineTo(colX(i), surf(i, t, back) - back * 5);
ctx.lineTo(W, H);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
}
function tick(now) {
var dt = Math.min(0.05, (now - last) / 1000);
last = now;
var t = now / 1000;
if (still) {
level = s.levelT;
h.fill(0);
} else {
// The cursor stirs the surface where it crosses it.
if (s.hover && s.px >= 0) {
var c = Math.round((s.px / W) * (N - 1));
if (Math.abs(s.py - surf(c, t, 0)) < 34) {
var f = Math.max(-260, Math.min(260, s.vy * 0.5 + Math.abs(s.vx) * 0.12));
for (var k = -2; k <= 2; k++) if (c + k >= 0 && c + k < N) v[c + k] += f * dt * 6 * (1 - Math.abs(k) * 0.3);
}
}
s.vx *= 0.8;
s.vy *= 0.8;
acc += dt;
while (acc >= STEP) {
step(STEP);
acc -= STEP;
}
}
var ink = Array.prototype.map.call(probe.children, function (el) { return getComputedStyle(el).color; });
ctx.clearRect(0, 0, W, H);
fill(ink[0], 1, t);
fill(ink[1], 0, t);
ctx.beginPath();
for (var i = 0; i < N; i++) ctx[i ? 'lineTo' : 'moveTo'](colX(i), surf(i, t, 0));
ctx.strokeStyle = ink[2];
ctx.globalAlpha = 0.7;
ctx.lineWidth = 1.5;
ctx.stroke();
// Bubbles rise and pop at the surface.
ctx.lineWidth = 1;
ctx.globalAlpha = 0.5;
bubbles.forEach(function (bb) {
if (!still) {
bb.y -= bb.sp * dt;
bb.x += Math.sin(t * 3 + bb.r * 7) * 0.15;
}
if (bb.y < surf(Math.round((bb.x / W) * (N - 1)), t, 0) + bb.r + 2) {
bb.y = H + 6 + ((bb.r * 13) % 30);
bb.x = 12 + ((bb.x * 7.3 + 41) % 206);
}
ctx.beginPath();
ctx.arc(bb.x, bb.y, bb.r, 0, Math.PI * 2);
ctx.stroke();
});
ctx.globalAlpha = 1;
var p = Math.max(0, Math.min(1, level));
pct.textContent = Math.round(p * 100) + '%';
used.textContent = Math.round(p * GB) + ' of ' + GB + ' GB used';
raf = requestAnimationFrame(tick);
}
function move(e) {
var r = root.getBoundingClientRect();
var x = e.clientX - r.left;
var y = e.clientY - r.top;
if (s.px >= 0) {
s.vx = (x - s.px) * 60;
s.vy = (y - s.py) * 60;
}
s.px = x;
s.py = y;
s.hover = true;
}
function leave() {
s.hover = false;
s.px = -1;
s.py = -1;
}
function toggle() {
clean = !clean;
s.levelT = clean ? CLEAN : FULL;
s.kick = 70;
btn.textContent = clean ? 'Undo' : 'Clean up';
root.setAttribute('aria-label', 'Storage: ' + Math.round(s.levelT * GB) + ' of ' + GB + ' GB used');
}
root.addEventListener('pointermove', move);
root.addEventListener('pointerleave', leave);
btn.addEventListener('click', toggle);
raf = requestAnimationFrame(tick);
return function () {
cancelAnimationFrame(raf);
root.removeEventListener('pointermove', move);
root.removeEventListener('pointerleave', leave);
btn.removeEventListener('click', toggle);
};
}
document.querySelectorAll('.card-tank').forEach(initCardTank);Goal: Create a "Tank" card.
Visual behavior: A storage card filled with liquid. Move the cursor across it and the liquid leans toward you and sloshes, stirring the surface where you cross it; Clean up drains it to a lower level with a splash, and the percentage follows.
Technique:
- The surface is a 1D wave of 47 columns: each frame every column springs toward its neighbours and its rest height, run at a fixed 120 steps per second for stability.
- A tilt spring leans the rest line toward the pointer's x and overshoots, which reads as slosh; changes in tilt also push the columns, so the body rocks.
- Where the pointer crosses near the surface it adds velocity to the columns under it from its own speed. The level is a spring too, kicked with a ripple when it changes.
- Two filled layers (a fainter back wave) and a surface line are drawn on a canvas with colours from probe elements; bubbles rise and pop at the surface; the readout follows the level.
Accessibility: A group labelled with the usage; Clean up and Undo are a real button. Under prefers-reduced-motion the surface is flat and level changes are instant.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Tank" card.
Visual behavior: A storage card filled with liquid. Move the cursor across it and the liquid leans toward you and sloshes, stirring the surface where you cross it; Clean up drains it to a lower level with a splash, and the percentage follows.
Technique:
- The surface is a 1D wave of 47 columns: each frame every column springs toward its neighbours and its rest height, run at a fixed 120 steps per second for stability.
- A tilt spring leans the rest line toward the pointer's x and overshoots, which reads as slosh; changes in tilt also push the columns, so the body rocks.
- Where the pointer crosses near the surface it adds velocity to the columns under it from its own speed. The level is a spring too, kicked with a ripple when it changes.
- Two filled layers (a fainter back wave) and a surface line are drawn on a canvas with colours from probe elements; bubbles rise and pop at the surface; the readout follows the level.
Accessibility: A group labelled with the usage; Clean up and Undo are a real button. Under prefers-reduced-motion the surface is flat and level changes are instant.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
A storage card filled with liquid. Move the cursor across it and the liquid leans toward you and sloshes, stirring the surface where you cross it; Clean up drains it to a lower level with a splash, and the percentage follows.
A group labelled with the usage; Clean up and Undo are a real button. Under prefers-reduced-motion the surface is flat and level changes are instant.