№ 002
Coral Nav
A soft coral drop that sinks into the bar and flows between tabs.
- Version
- 1.0.0
- Download
- 8 KB · 3 files
- Added
- Oct 26, 2026
- Built with
- HTML, CSS, JS
- Needs
- Google Fonts
- Tags
- tab bar, liquid, svg
Usage
Copy coral-nav.css and coral-nav.js (and assets/, if there is one) into your project, then:
new CoralNav(element, { tabs: [{ label, icon }], active: 0, onChange(tab, index) {} })
Active tab is exposed with aria-current="page". `icon` is a key of CoralNav.icons or a raw SVG string.
All geometry is in units of u = barHeight / 132. Shape coordinates are relative to the
drop's centre x and the bar's top edge (y = 0).Source
/* Coral nav — an ivory pill bar; the active tab is a matte coral dome that swells out of it. */
.cn {
--h: clamp(72px, 14vw, 99px);
--u: calc(var(--h) / 132);
--ink: #756762;
--ico: calc(var(--u) * 36);
position: relative;
width: 100%;
max-width: 684px;
height: var(--h);
margin-top: calc(var(--u) * 44);
user-select: none;
-webkit-tap-highlight-color: transparent;
font-size: clamp(12px, calc(var(--u) * 18), 18px);
}
.cn-bar,
.cn-drop {
position: absolute;
left: 0;
overflow: visible;
pointer-events: none;
}
.cn-bar {
filter:
drop-shadow(0 calc(var(--u) * 2) calc(var(--u) * 3) rgba(150, 105, 85, 0.1))
drop-shadow(0 calc(var(--u) * 16) calc(var(--u) * 26) rgba(150, 105, 85, 0.12));
}
.cn-drop {
filter:
drop-shadow(0 calc(var(--u) * 3) calc(var(--u) * 4) rgba(190, 95, 70, 0.18))
drop-shadow(0 calc(var(--u) * 10) calc(var(--u) * 14) rgba(190, 95, 70, 0.14));
}
.cn-tabs { position: absolute; inset: 0; }
.cn-tab {
position: absolute;
top: 0;
left: 0;
height: 100%;
min-width: 44px;
min-height: 44px;
padding: 0;
border: 0;
border-radius: calc(var(--u) * 30);
background: none;
color: var(--ink);
font: inherit;
cursor: pointer;
transition: color 0.2s;
will-change: transform;
}
.cn-tab:hover { color: #4f4440; }
.cn-tab:focus-visible {
outline: 2px solid #e9856b;
outline-offset: -6px;
}
.cn-tab[aria-current="page"] { pointer-events: none; }
.cn-icon,
.cn-name {
position: absolute;
left: 50%;
opacity: var(--f, 1);
}
.cn-icon {
top: calc(var(--u) * 52 - var(--ico) / 2);
display: grid;
width: var(--ico);
height: var(--ico);
transform: translateX(-50%) scale(calc(0.7 + 0.3 * var(--f, 1)));
}
.cn-name {
top: calc(var(--u) * 96);
transform: translateX(-50%) translateY(calc(var(--u) * 6 * (1 - var(--f, 1))));
translate: 0 -50%;
white-space: nowrap;
letter-spacing: 0.01em;
}
.cn-icon svg,
.cn-live-icon svg { width: 100%; height: 100%; }
.cn-live-icon svg { transform: scale(1.05); }
.cn-live {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 100%;
color: #fff;
pointer-events: none;
will-change: transform;
}
.cn-live-icon {
position: absolute;
left: 0;
top: calc(var(--u) * 30 - var(--ico) / 2);
display: grid;
width: var(--ico);
height: var(--ico);
translate: -50% 0;
}
.cn-live-name {
position: absolute;
left: 0;
top: calc(var(--u) * 83);
translate: -50% -50%;
white-space: nowrap;
letter-spacing: 0.01em;
}
.cn-live-dash {
position: absolute;
left: 0;
top: calc(var(--u) * 108);
width: calc(var(--u) * 38);
height: calc(var(--u) * 5);
translate: -50% -50%;
border-radius: 99px;
background: #fff;
}
.cn-live-icon.pop { animation: cn-pop 0.2s ease-out; }
.cn-live-name.pop { animation: cn-rise 0.2s ease-out; }
@keyframes cn-pop { from { opacity: 0; transform: scale(0.6) translateY(6px); } }
@keyframes cn-rise { from { opacity: 0; transform: translateY(5px); } }
@media (prefers-reduced-motion: reduce) {
.cn-live-icon.pop, .cn-live-name.pop { animation: none; }
} /* Coral nav — usage:
* new CoralNav(element, { tabs: [{ label, icon }], active: 0, onChange(tab, index) {} })
* Active tab is exposed with aria-current="page". `icon` is a key of CoralNav.icons or a raw SVG string.
*
* All geometry is in units of u = barHeight / 132. Shape coordinates are relative to the
* drop's centre x and the bar's top edge (y = 0).
*/
(function () {
const LEDGE_Y = 36; // height of the soft shoulders, below the bar's top edge
const WING = 113; // half-width of the coral at the shoulders
const DIP = 172; // half-width of the valley the bar dips into
const HILL_FOOT = 127; // half-width of the pale halo hill
const HILL_TOP = -39;
const BUMP_TOP = -29;
const BOTTOM = 124;
const EDGE = 1.15; // closest the drop gets to either end, in bar heights
const GAP = 1.3; // clear space kept between the drop and a neighbour, in bar heights
const STIFFNESS = 260; // drop spring: settles in ~500ms with a hint of overshoot
const DAMPING = 27;
const TAB_STIFFNESS = 170;
const TAB_DAMPING = 22;
const svg = (body) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
const ICONS = {
home: svg('<path d="M4 10.2 12 3.5l8 6.7"/><path d="M5.5 9V19a1.5 1.5 0 0 0 1.5 1.5h10a1.5 1.5 0 0 0 1.5-1.5V9"/><path d="M10 20.5v-4.8a.7.7 0 0 1 .7-.7h2.6a.7.7 0 0 1 .7.7v4.8"/>'),
user: svg('<circle cx="12" cy="7.5" r="3.8"/><path d="M4.5 20.5c.6-4.2 3.7-6.6 7.5-6.6s6.9 2.4 7.5 6.6"/>'),
chat: svg('<path d="M12 3.5a8.5 8.5 0 1 1-4.3 15.8L3.6 20.4l1.2-3.9A8.5 8.5 0 0 1 12 3.5Z"/>'),
camera: svg('<path d="M4 7.5h3.2l1.4-2.2a1 1 0 0 1 .85-.5h5.1a1 1 0 0 1 .85.5l1.4 2.2H20a1.5 1.5 0 0 1 1.5 1.5v9A1.5 1.5 0 0 1 20 19.5H4A1.5 1.5 0 0 1 2.5 18V9A1.5 1.5 0 0 1 4 7.5Z"/><circle cx="12" cy="13" r="3.6"/>'),
sliders: svg('<path d="M3 7h6.2M14.8 7H21M3 17h2.2M10.8 17H21"/><circle cx="12" cy="7" r="2.6"/><circle cx="8" cy="17" r="2.6"/>'),
};
let uid = 0;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
class CoralNav {
constructor(root, { tabs, active = 0, onChange } = {}) {
this.root = root;
this.tabs = tabs;
this.active = active;
this.onChange = onChange;
this.v = 0;
this.pendingLive = false;
this.running = false;
this.build();
this.layout();
this.x = this.target = this.stops[active];
this.pos = this.rest.slice();
this.posV = this.rest.map(() => 0);
this.select(active, { snap: false, force: true });
this.render();
new ResizeObserver(() => this.layout()).observe(root);
}
icon(key) {
return ICONS[key] || key;
}
build() {
const id = ++uid;
this.root.classList.add('cn');
this.root.innerHTML = `
<svg class="cn-bar" aria-hidden="true">
<defs>
<linearGradient id="cn-bar-${id}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fffdfb"/>
<stop offset="1" stop-color="#fcf8f4"/>
</linearGradient>
<linearGradient id="cn-hill-${id}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fef6f2"/>
<stop offset="1" stop-color="#f8dccf"/>
</linearGradient>
<mask id="cn-mask-${id}" maskUnits="userSpaceOnUse" x="-50" y="-200" width="4000" height="600">
<rect x="-50" y="-200" width="4000" height="600" fill="#fff"/>
<path class="cn-cut" fill="#000"/>
</mask>
</defs>
<path class="cn-hill" fill="url(#cn-hill-${id})" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/>
<g mask="url(#cn-mask-${id})">
<path class="cn-pill" fill="url(#cn-bar-${id})" stroke="#fff" stroke-width="1.5"/>
</g>
</svg>
<div class="cn-tabs"></div>
<svg class="cn-drop" aria-hidden="true">
<defs>
<linearGradient id="cn-fill-${id}" x1="0.15" y1="0" x2="0.85" y2="1">
<stop offset="0" stop-color="#f3a98f"/>
<stop offset="0.5" stop-color="#e9856b"/>
<stop offset="1" stop-color="#df785e"/>
</linearGradient>
<filter id="cn-soft-${id}" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="2.5"/>
</filter>
</defs>
<path class="cn-rim" fill="none" stroke="#f9d6c8" stroke-opacity="0.8" stroke-linejoin="round" filter="url(#cn-soft-${id})"/>
<path class="cn-fill" fill="url(#cn-fill-${id})"/>
</svg>
<div class="cn-live" aria-hidden="true">
<span class="cn-live-icon"></span>
<span class="cn-live-name"></span>
<span class="cn-live-dash"></span>
</div>`;
const q = (s) => this.root.querySelector(s);
this.barSvg = q('.cn-bar');
this.dropSvg = q('.cn-drop');
this.pill = q('.cn-pill');
this.hill = q('.cn-hill');
this.cut = q('.cn-cut');
this.rim = q('.cn-rim');
this.dropPaths = [this.rim, q('.cn-fill')];
this.live = q('.cn-live');
this.liveIcon = q('.cn-live-icon');
this.liveName = q('.cn-live-name');
const list = q('.cn-tabs');
this.buttons = this.tabs.map((tab, i) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'cn-tab';
b.setAttribute('aria-label', tab.label);
b.innerHTML = `<span class="cn-icon">${this.icon(tab.icon)}</span><span class="cn-name">${tab.label}</span>`;
b.addEventListener('click', () => this.select(i));
list.appendChild(b);
return b;
});
}
layout() {
const W = (this.w = this.root.clientWidth);
const H = (this.h = this.root.clientHeight);
this.u = H / 132;
const n = this.tabs.length;
const first = H * 0.8;
const spacing = n > 1 ? (W - first * 2) / (n - 1) : 0;
this.spacing = spacing;
this.base = this.tabs.map((_, i) => (n > 1 ? first + spacing * i : W / 2));
this.stops = this.base.map((c) => clamp(c, Math.min(H * EDGE, W / 2), Math.max(W - H * EDGE, W / 2)));
this.rest = this.restLayout(this.active);
this.slot = Math.max(H * 1.05, 44);
this.buttons.forEach((b) => {
b.style.width = `${this.slot}px`;
});
const top = Math.ceil(-HILL_TOP * this.u) + 6;
for (const el of [this.barSvg, this.dropSvg]) {
el.style.top = `${-top}px`;
el.setAttribute('width', W);
el.setAttribute('height', H + top);
el.setAttribute('viewBox', `0 ${-top} ${W} ${H + top}`);
}
const r = H / 2;
this.pill.setAttribute('d',
`M${r},0 L${W - r},0 A${r},${r} 0 0 1 ${W - r},${H} L${r},${H} A${r},${r} 0 0 1 ${r},0 Z`);
this.rim.setAttribute('stroke-width', 8 * this.u);
this.target = this.stops[this.active];
if (!this.running) {
this.x = this.target;
this.pos = this.rest.slice();
this.posV = this.rest.map(() => 0);
}
if (this.pos) this.render();
}
// Where every tab rests when `a` is active: neighbours are nudged clear of the drop.
restLayout(a) {
const { base, stops, h: H, spacing, w: W } = this;
const n = base.length;
const keep = Math.min(GAP * H, spacing * 1.9);
const gap = spacing * 0.75;
const p = base.slice();
p[a] = stops[a];
for (let j = a + 1; j < n; j++) p[j] = Math.max(base[j], p[j - 1] + (j === a + 1 ? keep : gap));
for (let j = a - 1; j >= 0; j--) p[j] = Math.min(base[j], p[j + 1] - (j === a - 1 ? keep : gap));
for (let j = n - 1; j > a; j--) p[j] = Math.min(p[j], W - H * 0.7 - (n - 1 - j) * gap);
for (let j = 0; j < a; j++) p[j] = Math.max(p[j], H * 0.7 + j * gap);
return p;
}
// The three shapes that share the drop's silhouette for the current motion.
shapes(cx, stretch, lean, squash) {
const u = this.u;
// k = how much a point follows the lean: the top leads, the bowl trails.
const P = (x, y, k = 0) =>
`${(cx + x * u * stretch + lean * k).toFixed(2)},${((LEDGE_Y + (y - LEDGE_Y) * squash) * u).toFixed(2)}`;
const L = LEDGE_Y;
// Shoulders are rounded (small radius) rather than pointed.
const drop = [
`M${P(-104, L - 1, 0.2)}`,
`C${P(-98, L - 2, 0.25)} ${P(-94, L - 4, 0.28)} ${P(-88, L - 7, 0.3)}`,
`C${P(-62, 20, 0.45)} ${P(-48, BUMP_TOP, 0.7)} ${P(0, BUMP_TOP, 0.7)}`,
`C${P(48, BUMP_TOP, 0.7)} ${P(62, 20, 0.45)} ${P(88, L - 7, 0.3)}`,
`C${P(94, L - 4, 0.28)} ${P(98, L - 2, 0.25)} ${P(104, L - 1, 0.2)}`,
`C${P(110, L - 1, 0.18)} ${P(115, L + 3, 0.15)} ${P(111, L + 10, 0.1)}`,
`C${P(98, 70, -0.2)} ${P(62, BOTTOM, -0.8)} ${P(0, BOTTOM, -0.9)}`,
`C${P(-62, BOTTOM, -0.8)} ${P(-98, 70, -0.2)} ${P(-111, L + 10, 0.1)}`,
`C${P(-115, L + 3, 0.15)} ${P(-110, L - 1, 0.18)} ${P(-104, L - 1, 0.2)} Z`,
].join(' ');
const hill = [
`M${P(-HILL_FOOT, 18, 0.15)}`,
`C${P(-100, 10, 0.3)} ${P(-50, HILL_TOP, 0.65)} ${P(0, HILL_TOP, 0.7)}`,
`C${P(50, HILL_TOP, 0.65)} ${P(100, 10, 0.3)} ${P(HILL_FOOT, 18, 0.15)}`,
`L${P(HILL_FOOT, 70)} L${P(-HILL_FOOT, 70)} Z`,
].join(' ');
// The valley the bar's top edge flows down into; its floor is hidden by the drop.
const cut = [
`M${P(-DIP, -80)} L${P(-DIP, 0)}`,
`C${P(-148, 0)} ${P(-122, 8, 0.1)} ${P(-112, L + 6, 0.15)}`,
`L${P(112, L + 6, 0.15)}`,
`C${P(122, 8, 0.1)} ${P(148, 0)} ${P(DIP, 0)}`,
`L${P(DIP, -80)} Z`,
].join(' ');
return { drop, hill, cut };
}
render() {
const speed = Math.min(Math.abs(this.v) / 2000, 1);
const stretch = 1 + speed * 0.16;
const squash = 1 - speed * 0.06;
const maxLean = this.h * 0.2;
const lean = clamp(this.v * 0.022, -maxLean, maxLean);
const s = this.shapes(this.x, stretch, lean, squash);
this.hill.setAttribute('d', s.hill);
this.cut.setAttribute('d', s.cut);
this.dropPaths.forEach((p) => p.setAttribute('d', s.drop));
this.live.style.transform = `translateX(${this.x + lean * 0.25}px)`;
// Resting icons fade out as the drop covers them and back in as it leaves.
const reach = this.h * 0.85;
this.buttons.forEach((b, i) => {
const c = this.pos[i];
b.style.transform = `translateX(${c - this.slot / 2}px)`;
const t = Math.min(Math.abs(this.x - c) / reach, 1);
b.style.setProperty('--f', (t * t * (3 - 2 * t)).toFixed(3));
});
if (this.pendingLive && Math.abs(this.x - this.target) < this.spacing * 0.55) this.setLive(this.active);
}
setLive(i, animate = true) {
this.pendingLive = false;
const tab = this.tabs[i];
this.liveIcon.innerHTML = this.icon(tab.icon);
this.liveName.textContent = tab.label;
for (const el of [this.liveIcon, this.liveName]) {
el.classList.remove("pop");
if (!animate) continue;
void el.offsetWidth;
el.classList.add("pop");
}
}
select(i, { snap = true, force = false } = {}) {
if (i !== this.active || force) {
this.active = i;
this.buttons.forEach((b, j) => {
if (j === i) b.setAttribute('aria-current', 'page');
else b.removeAttribute('aria-current');
});
this.rest = this.restLayout(i);
if (force || reducedMotion.matches) this.setLive(i, !force);
else this.pendingLive = true;
if (this.onChange) this.onChange(this.tabs[i], i);
}
if (snap) {
this.target = this.stops[i];
this.animate();
}
}
animate() {
if (reducedMotion.matches) {
this.x = this.target;
this.pos = this.rest.slice();
this.v = 0;
this.posV = this.rest.map(() => 0);
this.render();
return;
}
if (this.running) return;
this.running = true;
let last = performance.now();
const tick = (now) => {
const dt = Math.min((now - last) / 1000, 1 / 30);
last = now;
this.v += (-STIFFNESS * (this.x - this.target) - DAMPING * this.v) * dt;
this.x += this.v * dt;
let still = Math.abs(this.x - this.target) < 0.1 && Math.abs(this.v) < 1;
for (let j = 0; j < this.pos.length; j++) {
this.posV[j] += (-TAB_STIFFNESS * (this.pos[j] - this.rest[j]) - TAB_DAMPING * this.posV[j]) * dt;
this.pos[j] += this.posV[j] * dt;
if (Math.abs(this.pos[j] - this.rest[j]) > 0.1 || Math.abs(this.posV[j]) > 1) still = false;
}
if (still) {
this.x = this.target;
this.v = 0;
this.pos = this.rest.slice();
this.posV = this.rest.map(() => 0);
this.running = false;
}
this.render();
if (this.running) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
}
CoralNav.icons = ICONS;
window.CoralNav = CoralNav;
})(); <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Coral Nav</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Figtree:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="coral-nav.css">
<style>
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
display: grid;
place-items: center;
padding: 16px;
background: #faf7f3;
color: #756762;
font-family: "Figtree", system-ui, sans-serif;
}
#nav { max-width: 684px; }
</style>
</head>
<body>
<nav id="nav" aria-label="Main"></nav>
<script src="coral-nav.js"></script>
<script>
new CoralNav(document.getElementById('nav'), {
tabs: [
{ label: 'Home', icon: 'home' },
{ label: 'Profile', icon: 'user' },
{ label: 'Chat', icon: 'chat' },
{ label: 'Camera', icon: 'camera' },
{ label: 'Settings', icon: 'sliders' },
],
active: 0,
});
</script>
</body>
</html>