№ 006
Tide Nav
A tab bar filled with water — switching tabs sends a wave across the surface.
- Version
- 1.0.0
- Download
- 8 KB · 3 files
- Added
- Oct 26, 2026
- Built with
- HTML, CSS, JS
- Needs
- Nothing
- Tags
- tab bar, water, simulation
Usage
Copy tide-nav.css and tide-nav.js (and assets/, if there is one) into your project, then:
new TideNav(element, { tabs: [{ label, icon }], active: 0, onChange(tab, index) {} })
`icon` is a key of TideNav.icons or a raw SVG string.Source
/* Tide nav — a glass capsule filled with live water; the active tab rides the surface as a droplet. */
.td {
--td-deep: #0f4c5c;
--td-spring: cubic-bezier(0.34, 1.4, 0.5, 1);
--td-smooth: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
width: min(calc(100vw - 32px), 356px);
height: 76px;
animation: td-rise 0.9s var(--td-smooth) both;
-webkit-tap-highlight-color: transparent;
}
@keyframes td-rise {
from { opacity: 0; transform: translateY(30px) scale(0.94); }
}
.td-filters {
position: absolute;
width: 0;
height: 0;
}
.td-capsule {
position: relative;
width: 100%;
height: 100%;
border-radius: 999px;
isolation: isolate;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.18));
-webkit-backdrop-filter: blur(16px) saturate(160%);
backdrop-filter: blur(16px) saturate(160%);
box-shadow:
0 1px 1px rgba(20, 80, 100, 0.1),
0 8px 14px -4px rgba(20, 110, 140, 0.18),
0 26px 36px -14px rgba(20, 130, 165, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.95),
inset 0 -2px 6px rgba(20, 120, 150, 0.25),
inset 0 0 22px rgba(255, 255, 255, 0.35);
touch-action: pan-y;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
}
/* bevelled rim */
.td-capsule::before {
content: "";
position: absolute;
inset: 0;
z-index: 6;
padding: 1.3px;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(120deg, #fff 0%, rgba(255, 255, 255, 0.15) 30%, rgba(255, 255, 255, 0.1) 65%, rgba(255, 255, 255, 0.95) 100%);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
}
/* curved window reflection */
.td-capsule::after {
content: "";
position: absolute;
z-index: 5;
left: 6%;
right: 38%;
top: 4px;
height: 17px;
border-radius: 999px;
pointer-events: none;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.75), rgba(255, 255, 255, 0.02));
filter: blur(0.4px);
}
.td-clip {
position: absolute;
inset: 0;
border-radius: inherit;
overflow: hidden;
}
/* underwater icons, wobbled by a turbulence filter (set inline per instance) */
.td-tabs {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
}
.td-tab {
flex: 1;
display: grid;
place-items: center;
padding: 14px 0 0;
border: 0;
outline: none;
background: none;
color: rgba(6, 44, 58, 0.95);
cursor: pointer;
transition: color 0.3s, opacity 0.3s, transform 0.5s var(--td-spring);
}
.td-tab svg {
width: 25px;
height: 25px;
transition: transform 0.5s var(--td-spring), opacity 0.25s;
}
.td-tab.is-active svg { opacity: 0; transform: translateY(-14px) scale(0.6); }
.td-tab:active svg { transform: scale(0.85); }
.td-tab:focus-visible { border-radius: 999px; box-shadow: inset 0 0 0 2px rgba(15, 76, 92, 0.9); }
.td-water {
position: absolute;
inset: 0;
z-index: 2;
width: 100%;
height: 100%;
pointer-events: none;
}
/* the droplet */
.td-drop {
position: absolute;
left: 0;
top: 0;
z-index: 4;
width: 54px;
height: 54px;
margin: -27px 0 0 -27px;
border-radius: 50%;
pointer-events: none;
will-change: transform;
background:
radial-gradient(circle at 30% 24%, rgba(255, 255, 255, 0.98) 0 6%, rgba(255, 255, 255, 0.3) 20%, rgba(255, 255, 255, 0) 38%),
radial-gradient(circle at 50% 118%, rgba(70, 215, 230, 0.75), rgba(70, 215, 230, 0) 62%),
radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0.06) 72%);
-webkit-backdrop-filter: blur(2.5px) saturate(190%) brightness(1.1);
backdrop-filter: blur(2.5px) saturate(190%) brightness(1.1);
box-shadow:
inset 0 0 0 1.3px rgba(255, 255, 255, 0.75),
inset 0 -9px 14px rgba(0, 130, 170, 0.32),
inset 0 7px 10px rgba(255, 255, 255, 0.6),
0 12px 18px -7px rgba(10, 110, 150, 0.55);
}
/* caustic crescent */
.td-drop::after {
content: "";
position: absolute;
left: 20%;
right: 14%;
bottom: 7%;
height: 28%;
border-radius: 50%;
background: radial-gradient(closest-side, rgba(190, 255, 255, 0.65), transparent);
filter: blur(1px);
transform: rotate(-12deg);
}
.td-glyph {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--td-deep);
}
.td-glyph svg {
width: 26px;
height: 26px;
stroke-width: 1.9;
filter: drop-shadow(0 1px 0 rgba(255, 255, 255, 0.7));
animation: td-glyph-in 0.5s var(--td-spring);
}
@keyframes td-glyph-in {
from { opacity: 0; transform: scale(0.4) translateY(10px); }
}
@media (prefers-reduced-motion: reduce) {
.td, .td-glyph svg { animation-duration: 0.01ms !important; }
} /* Tide nav — usage:
* new TideNav(element, { tabs: [{ label, icon }], active: 0, onChange(tab, index) {} })
* `icon` is a key of TideNav.icons or a raw SVG string.
*/
(function () {
const SURF = 35; // resting water-line (px from top)
const DX = 6; // width of one water column
const K = 2400; // wave propagation stiffness
const DAMP = 2.6;
const SPRING = 20; // pull back towards the resting line
const GRAVITY = 1500;
const svg = (body) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
const ICONS = {
home: svg('<path d="M4 11.2 12 4.5l8 6.7V19a1.5 1.5 0 0 1-1.5 1.5H15v-5.2a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v5.2H5.5A1.5 1.5 0 0 1 4 19z"/>'),
search: svg('<circle cx="10.8" cy="10.8" r="6.3"/><path d="m15.6 15.6 4.4 4.4"/>'),
create: svg('<circle cx="12" cy="12" r="8.5"/><path d="M12 8.4v7.2M8.4 12h7.2"/>'),
heart: svg('<path d="M12 20s-7.5-4.5-7.5-10A4.2 4.2 0 0 1 12 7.6 4.2 4.2 0 0 1 19.5 10C19.5 15.5 12 20 12 20z"/>'),
user: svg('<circle cx="12" cy="8.3" r="3.6"/><path d="M4.8 20c.9-3.7 3.8-5.6 7.2-5.6s6.3 1.9 7.2 5.6z"/>'),
};
let uid = 0;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
const rand = (a, b) => a + Math.random() * (b - a);
class TideNav {
constructor(root, { tabs, active = 0, onChange } = {}) {
this.root = root;
this.tabs = tabs;
this.active = -1;
this.onChange = onChange;
const reduce = reducedMotion.matches;
this.level = reduce ? 1 : 0; // how full the capsule is (0..1)
this.landed = reduce; // has the droplet dropped in yet
this.dragging = false;
this.t = 0;
this.bubbles = [];
this.rings = [];
this.build();
this.layout();
this.x = this.target = this.centers[active];
this.vx = 0;
this.y = -80;
this.vy = 0;
this.select(active, { silent: true });
new ResizeObserver(() => {
this.layout();
this.x = this.target = this.centers[this.active];
}).observe(root);
requestAnimationFrame((t0) => {
this.last = t0;
this.frame(t0);
});
}
icon(key) {
return ICONS[key] || key;
}
build() {
const fid = `td-underwater-${++uid}`;
this.root.classList.add('td');
this.root.innerHTML = `
<svg class="td-filters" aria-hidden="true">
<filter id="${fid}" x="-5%" y="-5%" width="110%" height="110%">
<feTurbulence type="fractalNoise" baseFrequency=".012 .05" numOctaves="1" seed="3" result="n">
<animate attributeName="baseFrequency" dur="7s" values=".012 .05;.02 .07;.012 .05" repeatCount="indefinite"/>
</feTurbulence>
<feDisplacementMap in="SourceGraphic" in2="n" scale="3.2" xChannelSelector="R" yChannelSelector="G"/>
</filter>
</svg>
<div class="td-capsule">
<div class="td-clip">
<div class="td-tabs" role="tablist" aria-label="Main" style="filter: url(#${fid})"></div>
<canvas class="td-water" aria-hidden="true"></canvas>
</div>
<div class="td-drop" aria-hidden="true"><div class="td-glyph"></div></div>
</div>`;
this.cap = this.root.querySelector('.td-capsule');
this.canvas = this.root.querySelector('.td-water');
this.ctx = this.canvas.getContext('2d');
this.drop = this.root.querySelector('.td-drop');
this.glyph = this.root.querySelector('.td-glyph');
const list = this.root.querySelector('.td-tabs');
this.buttons = this.tabs.map((tab, i) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'td-tab';
b.setAttribute('role', 'tab');
b.setAttribute('aria-label', tab.label);
b.innerHTML = this.icon(tab.icon);
// pointer taps are handled on the capsule; this catches keyboard activation
b.addEventListener('click', (e) => {
if (e.detail === 0) this.go(i, 70);
});
list.appendChild(b);
return b;
});
this.cap.addEventListener('keydown', (e) => {
const last = this.tabs.length - 1;
const next = { ArrowRight: this.active + 1, ArrowLeft: this.active - 1, Home: 0, End: last }[e.key];
if (next === undefined) return;
e.preventDefault();
const i = clamp(next, 0, last);
this.go(i, 70);
this.buttons[i].focus();
});
this.cap.addEventListener('pointerdown', (e) => this.dragStart(e));
this.cap.addEventListener('pointermove', (e) => this.dragMove(e));
this.cap.addEventListener('pointerup', () => this.dragEnd());
this.cap.addEventListener('pointercancel', () => this.dragEnd());
}
layout() {
this.dpr = Math.min(window.devicePixelRatio || 1, 2.5);
this.w = this.cap.clientWidth;
this.h = this.cap.clientHeight;
this.canvas.width = this.w * this.dpr;
this.canvas.height = this.h * this.dpr;
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
this.cols = Math.ceil(this.w / DX) + 1;
this.heights = new Float32Array(this.cols);
this.speeds = new Float32Array(this.cols);
this.centers = this.tabs.map((_, i) => ((i + 0.5) * this.w) / this.tabs.length);
}
select(i, { silent = false } = {}) {
if (i === this.active) return;
this.active = i;
this.buttons.forEach((b, k) => {
b.classList.toggle('is-active', k === i);
b.setAttribute('aria-selected', k === i);
b.tabIndex = k === i ? 0 : -1;
});
this.glyph.innerHTML = this.icon(this.tabs[i].icon);
if (silent) return;
this.spawnBubbles(this.centers[i], 7, 20);
navigator.vibrate?.(6);
this.onChange?.(this.tabs[i], i);
}
// Select a tab and send the droplet there with a splash.
go(i, power) {
this.select(i);
this.target = this.centers[i];
this.splash(this.centers[i], power);
}
/* ── input ── */
pointerX(e) {
return clamp(e.clientX - this.cap.getBoundingClientRect().left, 0, this.w);
}
nearest(x) {
return clamp(Math.floor(x / (this.w / this.tabs.length)), 0, this.tabs.length - 1);
}
follow(x) {
this.target = clamp(x, this.centers[0], this.centers[this.centers.length - 1]);
this.select(this.nearest(x));
}
dragStart(e) {
if (!this.landed) return;
this.dragging = true;
this.cap.setPointerCapture(e.pointerId);
const x = this.pointerX(e);
this.follow(x);
this.splash(x, 85);
this.rings.push({ x, age: 0, life: 0.9 });
}
dragMove(e) {
if (this.dragging) this.follow(this.pointerX(e));
}
dragEnd() {
if (!this.dragging) return;
this.dragging = false;
this.target = this.centers[this.active];
}
/* ── the water: a 1-D height field that really propagates waves ── */
colOf(x) {
return clamp(Math.round(x / DX), 0, this.cols - 1);
}
splash(x, power) {
const c = this.colOf(x);
for (let j = -4; j <= 4; j++) {
const k = c + j;
if (k >= 0 && k < this.cols) this.speeds[k] -= power * Math.exp((-j * j) / 4);
}
}
stepWater(dt) {
const h = this.heights, v = this.speeds, n = this.cols, sub = 2, d = dt / sub;
for (let s = 0; s < sub; s++) {
for (let i = 0; i < n; i++) {
const l = h[i > 0 ? i - 1 : i], r = h[i < n - 1 ? i + 1 : i];
v[i] += (K * (l + r - 2 * h[i]) - SPRING * h[i] - DAMP * v[i]) * d;
}
for (let i = 0; i < n; i++) h[i] = clamp(h[i] + v[i] * d, -13, 13);
}
}
baseY() {
return this.h + (SURF - this.h) * this.level;
}
ambient(x, phase, amp) {
const t = this.t;
return (Math.sin(x * 0.045 + t * 1.5 + phase) * 1.7 + Math.sin(x * 0.09 - t * 1.05 + phase * 2) * 0.9) * amp * this.level;
}
surfaceAt(x) {
const h = this.heights;
const f = clamp(x / DX, 0, this.cols - 1), i = Math.floor(f), j = Math.min(i + 1, this.cols - 1), m = f - i;
return this.baseY() + h[i] * (1 - m) + h[j] * m + this.ambient(x, 0, 1);
}
spawnBubbles(x, n, spread = 14) {
for (let i = 0; i < n; i++) {
this.bubbles.push({ x: x + rand(-spread, spread), y: this.h - rand(2, 16), r: rand(1.6, 4.4), vy: rand(16, 42), ph: rand(0, 6.28), sw: rand(0.4, 1.1) });
}
}
/* ── drawing ── */
wavePath(offset, phase, amp, hAmt) {
const { ctx, w: W, h: H, heights: h } = this;
ctx.beginPath();
ctx.moveTo(0, H + 2);
let px = 0, py = this.baseY() + h[0] * hAmt + this.ambient(0, phase, amp) + offset;
ctx.lineTo(px, py);
for (let i = 1; i < this.cols; i++) {
const x = i * DX, y = this.baseY() + h[i] * hAmt + this.ambient(x, phase, amp) + offset;
ctx.quadraticCurveTo(px, py, (px + x) / 2, (py + y) / 2);
px = x;
py = y;
}
ctx.lineTo(W + 2, py);
ctx.lineTo(W + 2, H + 2);
ctx.closePath();
}
draw() {
const { ctx, w: W, h: H, t } = this;
ctx.clearRect(0, 0, W, H);
const top = this.baseY();
// back layer
this.wavePath(-2.5, 1.4, 1.15, 0.75);
let g = ctx.createLinearGradient(0, top - 6, 0, H);
g.addColorStop(0, 'rgba(165, 238, 246, .42)');
g.addColorStop(1, 'rgba(70, 185, 215, .5)');
ctx.fillStyle = g;
ctx.fill();
// soft caustics drifting on the "floor"
ctx.save();
this.wavePath(0, 0, 1, 1);
ctx.clip();
for (let i = 0; i < 6; i++) {
const cx = ((i * 71 + t * (14 + i * 3)) % (W + 60)) - 30, cy = H - 9 + Math.sin(t * 1.3 + i) * 3;
const rg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 26);
rg.addColorStop(0, 'rgba(255,255,255,.34)');
rg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = rg;
ctx.fillRect(cx - 28, cy - 18, 56, 36);
}
ctx.restore();
// front layer
this.wavePath(0, 0, 1, 1);
g = ctx.createLinearGradient(0, top - 6, 0, H);
g.addColorStop(0, 'rgba(120, 228, 240, .42)');
g.addColorStop(0.5, 'rgba(45, 175, 205, .46)');
g.addColorStop(1, 'rgba(15, 125, 170, .66)');
ctx.fillStyle = g;
ctx.fill();
// bright meniscus along the surface
ctx.beginPath();
for (let i = 0; i <= this.cols; i++) {
const x = Math.min(i * DX, W), y = this.surfaceAt(x);
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
}
ctx.lineJoin = 'round';
ctx.strokeStyle = 'rgba(255,255,255,.95)';
ctx.lineWidth = 1.6;
ctx.stroke();
ctx.strokeStyle = 'rgba(255,255,255,.28)';
ctx.lineWidth = 5;
ctx.stroke();
// ripple rings
this.rings.forEach((r) => {
const a = 1 - r.age / r.life;
ctx.beginPath();
ctx.ellipse(r.x, this.surfaceAt(r.x) + 1, r.age * 70, r.age * 9, 0, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(255,255,255,${0.7 * a})`;
ctx.lineWidth = 1.4;
ctx.stroke();
});
// bubbles
this.bubbles.forEach((b) => {
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,.14)';
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,.8)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.beginPath();
ctx.arc(b.x - b.r * 0.35, b.y - b.r * 0.35, Math.max(0.6, b.r * 0.26), 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,.95)';
ctx.fill();
});
}
/* ── main loop ── */
frame(now) {
const dt = Math.min((now - this.last) / 1000 || 0.016, 0.033);
this.last = now;
this.t += dt;
if (this.level < 1) this.level = Math.min(1, this.level + dt * (1.1 - this.level * 0.7)); // the capsule fills up
this.stepWater(dt);
// droplet: falls in on load, then springs along x
if (!this.landed) {
if (this.level > 0.72) {
this.vy += GRAVITY * dt;
this.y += this.vy * dt;
const s = this.surfaceAt(this.x) + 4;
if (this.y >= s) {
this.landed = true;
this.y = s;
this.vy = 0;
this.splash(this.x, 190);
this.rings.push({ x: this.x, age: 0, life: 1.1 });
this.spawnBubbles(this.x, 12, 22);
}
}
} else {
const stiff = this.dragging ? 420 : 190, damp = this.dragging ? 30 : 17;
this.vx += ((this.target - this.x) * stiff - this.vx * damp) * dt;
this.x += this.vx * dt;
this.y += (this.surfaceAt(this.x) + 4 - this.y) * Math.min(1, dt * 16);
if (Math.abs(this.vx) > 25) this.speeds[this.colOf(this.x)] -= Math.abs(this.vx) * 0.045; // the droplet drags a wake
}
const sq = clamp(Math.abs(this.vx) / 1100, 0, 0.42);
const slope = (this.surfaceAt(this.x + 6) - this.surfaceAt(this.x - 6)) / 12;
this.drop.style.transform =
`translate(${this.x.toFixed(2)}px, ${this.y.toFixed(2)}px) ` +
`rotate(${(this.vx / 70 + slope * 18).toFixed(2)}deg) ` +
`scale(${(1 + sq).toFixed(3)}, ${(1 - sq * 0.55).toFixed(3)})`;
// bubbles & rings
if (this.landed && Math.random() < dt * 2.6) this.spawnBubbles(rand(14, this.w - 14), 1, 0);
for (let i = this.bubbles.length - 1; i >= 0; i--) {
const b = this.bubbles[i];
b.y -= b.vy * dt;
b.ph += dt * 3;
b.x += Math.sin(b.ph) * b.sw * 0.6;
if (b.y < this.surfaceAt(b.x) + 1) {
this.splash(b.x, b.r * 6);
this.bubbles.splice(i, 1);
}
}
for (let i = this.rings.length - 1; i >= 0; i--) {
this.rings[i].age += dt;
if (this.rings[i].age > this.rings[i].life) this.rings.splice(i, 1);
}
this.draw();
requestAnimationFrame((t) => this.frame(t));
}
}
TideNav.icons = ICONS;
window.TideNav = TideNav;
})(); <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Tide Nav</title>
<link rel="stylesheet" href="tide-nav.css">
<style>
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
display: grid;
place-items: center;
padding: 16px;
overflow: hidden;
background: #f3e9d7;
color: #3a2c20;
font-family: "SF Pro Rounded", "SF Pro Text", system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
</style>
</head>
<body>
<nav id="nav" aria-label="Primary"></nav>
<script src="tide-nav.js"></script>
<script>
new TideNav(document.getElementById('nav'), {
tabs: [
{ label: 'Home', icon: 'home' },
{ label: 'Search', icon: 'search' },
{ label: 'Create', icon: 'create' },
{ label: 'Likes', icon: 'heart' },
{ label: 'Profile', icon: 'user' },
],
});
</script>
</body>
</html>