This is the whole build of Drop tabs, in the order it was written. Each stage below is a live page: tap, drag and let go on it. The code under each heading is only what that stage adds; the finished source is at the end.
Four files do the work from the start: index.html for the markup, style.css for the
look, app.js for the shape and the motion, and icons.js for the five icons (the same
file as in Liquid tabs). From stage 3 there is a fifth, spring.js.
1 · The bar and the drop are one outline
Start with buttons, not with the effect. Five <button>s in a role="tablist"
give you focus and a screen-reader announcement for free; each keeps its name in a visually hidden
.label. Behind them sits one SVG, hidden from assistive technology because it is only decoration.
The whole liquid — the bar and the drop on top of it — is one path, drawn twice: once filled, once as a
thin stroke (.rim) with the same d.
<nav class="tabbar" role="tablist" aria-label="Main">
<!-- the liquid: the bar and the drop are ONE path (its outline runs over the drop), computed in app.js -->
<svg class="liquid" viewBox="0 0 344 150" aria-hidden="true">
<defs>
<!-- the liquid's colour, across the whole bar (so the drop takes the colour of where it is) -->
<linearGradient id="liq" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="344" y2="0">
<stop offset="0" class="liq-a"/><stop offset="1" class="liq-b"/>
</linearGradient>
</defs>
<g class="body" fill="url(#liq)">
<path class="shape" d=""/>
</g>
<path class="rim" d=""/> <!-- the contour: the same outline, stroked -->
</svg>
<div class="tabs">
<button class="tab" role="tab" data-icon="home" aria-label="Home"><span class="label">Home</span></button>
<!-- Profile, Messages, Photos and Settings: the same button -->
</div>
</nav>
A common way to melt two shapes together is an SVG "goo" filter: blur them, then cut the blur off at a hard
alpha threshold. This bar does not use one. The outline is computed exactly, so its edge stays sharp and can
carry a 1.4 px contour that runs up the neck and over the drop. The gradient is set in
userSpaceOnUse across the full width, so the drop takes the colour of wherever it stands.
.tabbar { position: relative; width: 344px; height: 150px; margin-top: -40px; touch-action: none; }
.liquid { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; filter: drop-shadow(0 18px 26px rgba(0, 0, 0, .35)); }
.liq-a { stop-color: var(--liq-a); } .liq-b { stop-color: var(--liq-b); }
.rim { fill: none; stroke: var(--rim, rgba(255, 255, 255, .55)); stroke-width: 1.4; pointer-events: none; }
.tabs { position: absolute; left: 0; right: 0; bottom: 0; height: 72px; padding: 0 22px; display: grid; grid-template-columns: repeat(5, 1fr); }
.tab { display: grid; place-items: center; padding: 0; border: 0; background: none; cursor: pointer; color: var(--on-liq); position: relative; z-index: 2; }
.tab .label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
The SVG is 344 × 150 and drawn 1:1 in the bar, so its units are the bar's pixels. The flat bar starts at
SURFACE (y 78) and is 72 px tall with round ends; the tabs are inset 22 px, which gives each one a
60 px slot. The drop has a radius of 26 and rests with its centre at y 44, 8 px clear of the surface.
// ---- geometry (bar px; the SVG is drawn 1:1) ----
const W = 344, SURFACE = 78, MID = 114, R = 26;
const EDGE = 22; // the tabs sit this far in, so every drop stands on the flat top
const slot = (W - 2 * EDGE) / tabs.length;
const centre = (i) => EDGE + slot * (i + 0.5);
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const HIGH = SURFACE - 34; // drop centre at rest: held by a short neck, its outline one with the bar's
outline() is the heart of the component. It walks the bar's top edge from the left, leaves it at
the neck's left foot, curves up to where the neck meets the drop, goes over the top of the drop with an arc,
comes down the other side the same way and carries on round the bar. One number shapes the neck:
t, how firmly the drop is still held. The lower the drop sits, the higher t, the
further round the drop the neck meets it and the wider its feet. Near the round ends of the bar,
room narrows both feet alike, so the neck never spills over the curve.
// ---- the silhouette: the bar and the drop as ONE outline ----
// Along the bar's top edge, up a concave curve, over the top of the drop, down the other side and on.
// t = how strongly the drop still holds on (1 = sitting in the liquid, 0 = about to let go).
const NECK = 22; // how far the drop can rise above the surface before it lets go
const f = (x, y) => `${x.toFixed(2)} ${y.toFixed(2)}`;
function outline(cx, cy, r) {
const gap = SURFACE - (cy + r);
const t = clamp(1 - gap / NECK, 0, 1);
let a = 0.42 + 0.55 * t; // where the neck meets the drop, measured from straight down
a = Math.max(a, Math.acos(clamp((SURFACE - 2 - cy) / r, -1, 1)) + 0.08); // never below the surface line
let px = r * Math.sin(a);
const w = px + 11 + 12 * t; // where the neck leaves the surface: a wide, soft fillet
const room = Math.min(cx - 30, W - 30 - cx); // near the rounded ends: narrow both sides alike
const ww = Math.min(w, room);
if (px > ww - 5) { a = Math.asin(clamp((ww - 5) / r, 0, 1)); px = r * Math.sin(a); } // the neck always meets the drop inside its feet
const py = cy + r * Math.cos(a);
const L = Math.max(6, (SURFACE - py) * 1.05 + 6 * t);
const lf = cx - ww, rf = cx + ww;
const c1l = [lf + (cx - px - lf) * 0.7, SURFACE], c1r = [rf - (rf - cx - px) * 0.7, SURFACE];
const c2l = [cx - px + Math.cos(a) * L * 0.6, py + Math.sin(a) * L * 0.6];
const c2r = [cx + px - Math.cos(a) * L * 0.6, py + Math.sin(a) * L * 0.6];
const large = 2 * Math.PI - 2 * a > Math.PI ? 1 : 0; // the arc over the top of the drop
return `M36 ${SURFACE} L${f(lf, SURFACE)} C${f(...c1l)} ${f(...c2l)} ${f(cx - px, py)}`
+ ` A${r} ${r} 0 ${large} 1 ${f(cx + px, py)} C${f(...c2r)} ${f(...c1r)} ${f(rf, SURFACE)}`
+ ` L${W - 36} ${SURFACE} A36 36 0 0 1 ${W - 36} ${SURFACE + 72} L36 ${SURFACE + 72} A36 36 0 0 1 36 ${SURFACE} Z`;
}
For now the drop just jumps. Choosing a tab redraws the outline over that tab and moves its icon up by
HIGH − MID (−70 px), from the middle of the tab row to the centre of the drop.
// ---- selection ----
let index = 0;
function select(i) {
index = i;
tabs.forEach((tab, k) => tab.setAttribute('aria-selected', String(k === i)));
render();
}
function render() {
const d = outline(centre(index), HIGH, R);
shape.setAttribute('d', d); rim.setAttribute('d', d); // the fill and its contour: one path
// the chosen icon moves up from the middle of the bar to the centre of the drop
icons.forEach((ic, k) => { ic.style.transform = k === index ? `translateY(${HIGH - MID}px)` : ''; });
}
tabs.forEach((tab, i) => tab.addEventListener('click', () => select(i)));
select(0);
2 · The drop follows the finger
Pointer events cover a mouse, a finger and a pen with one set of listeners. setPointerCapture
keeps the moves coming even when the finger slides off the bar. The whole 9:16 frame is scaled to fit the
screen, so fingerX divides by that scale to get back to bar pixels, and it stops at the first and
last tab so the drop never runs onto the round ends.
const nearest = (x) => clamp(Math.round((x - EDGE) / slot - 0.5), 0, tabs.length - 1);
// ---- the finger ----
let pointer = null;
function fingerX(e) {
const r = bar.getBoundingClientRect(), k = r.width / bar.offsetWidth; // the frame is scaled: screen px → bar px
return clamp((e.clientX - r.left) / k, centre(0), centre(tabs.length - 1));
}
function showTouch(e) { touch.style.transform = `translate(${e.clientX}px, ${e.clientY}px)`; touch.classList.add('is-on'); }
bar.addEventListener('pointerdown', (e) => {
if (e.button > 0) return;
bar.setPointerCapture(e.pointerId); // keep getting moves even when the finger leaves the bar
pointer = e.pointerId;
x = fingerX(e); render(); showTouch(e);
});
bar.addEventListener('pointermove', (e) => {
if (e.pointerId !== pointer) return;
x = fingerX(e); render(); showTouch(e);
});
function release(e) {
if (e.pointerId !== pointer) return;
pointer = null;
touch.classList.remove('is-on');
select(nearest(x));
}
bar.addEventListener('pointerup', release);
bar.addEventListener('pointercancel', release);
The click listener from stage 1 is gone: a press and a release in the same place is a tap, and letting go
anywhere selects the nearest tab. The arrow keys move the selection too, and only the selected tab is in the
tab order (tabIndex 0, the others −1). While the drop is between tabs, the nearest tab's icon rides
along inside it — a stopgap that the next stage replaces.
function select(i) {
index = i;
tabs.forEach((tab, k) => { tab.setAttribute('aria-selected', String(k === i)); tab.tabIndex = k === i ? 0 : -1; });
x = centre(i);
render();
}
bar.addEventListener('keydown', (e) => {
const step = { ArrowRight: 1, ArrowLeft: -1 }[e.key];
if (!step) return;
e.preventDefault(); select(clamp(index + step, 0, tabs.length - 1)); tabs[index].focus();
});
// in render(): the icon of the nearest tab rides in the drop, wherever the finger takes it; the others stay in the bar
const n = nearest(x);
icons.forEach((ic, k) => { ic.style.transform = k === n ? `translate(${(x - centre(k)).toFixed(2)}px, ${HIGH - MID}px)` : ''; });
3 · Springs make it liquid
Every motion from here on comes from one small class, the same spring.js as in Liquid tabs. A
spring has a value, a target and a velocity; each frame it accelerates towards the target
and a damper slows it down. Low damping overshoots, high damping creeps in, and the target can change in the
middle of a move without a jump.
// A spring, written from scratch: a mass on a spring with a damper.
// acceleration = stiffness × (target − value) − damping × velocity
// It is stepped in small fixed slices (1/240 s), so it moves the same on a 60 Hz and a 120 Hz screen.
// Changing the target never moves the value: an interrupted animation just bends towards the new target.
class Spring {
constructor(value = 0, { stiffness = 170, damping = 26 } = {}) {
this.value = value;
this.target = value;
this.velocity = 0;
this.stiffness = stiffness;
this.damping = damping;
}
set(options) { Object.assign(this, options); return this; }
step(dt) {
const slice = 1 / 240;
for (let t = dt; t > 0; t -= slice) {
const h = Math.min(slice, t);
const acceleration = this.stiffness * (this.target - this.value) - this.damping * this.velocity;
this.velocity += acceleration * h;
this.value += this.velocity * h;
}
return this.value;
}
resting(epsilon = 0.01) {
return Math.abs(this.target - this.value) < epsilon && Math.abs(this.velocity) < epsilon * 10;
}
}
Two springs run the drop. x is where it is: a stiff FOLLOW while a finger is down,
a softer SETTLE after. rise is how far out of the bar it stands: 1 is the drop at rest,
0 is a low bump whose top peeks 10 px above the surface. The trick is the target of rise: it
depends on how far x still has to go. Far from the new tab it is 0, so the drop sinks and travels
as a wave; over the last 0.9 of a slot it climbs back to 1, and the spring's low damping gives it one soft
bounce. During a drag (more than 8 px of movement) it is held at 0.1, so a low bump follows the finger.
const ease = (u) => { u = clamp(u, 0, 1); return u * u * (3 - 2 * u); };
const LOW = SURFACE + 16; // drop centre when it is a wave (its top peeks 10 px above the surface)
const HIGH = SURFACE - 34; // drop centre at rest: held by a short neck, its outline one with the bar's
// ---- springs ----
const SETTLE = { stiffness: 190, damping: 24 };
const FOLLOW = { stiffness: 380, damping: 36 };
const x = new Spring(centre(0), SETTLE);
const rise = new Spring(1, { stiffness: 190, damping: 17 }); // one soft bounce
bar.addEventListener('pointerdown', (e) => {
if (e.button > 0) return;
bar.setPointerCapture(e.pointerId); // keep getting moves even when the finger leaves the bar
pointer = e.pointerId; dragged = false; startX = e.clientX;
x.set(FOLLOW).target = fingerX(e);
showTouch(e); wake();
});
bar.addEventListener('pointermove', (e) => {
if (e.pointerId !== pointer) return;
if (Math.abs(e.clientX - startX) > 8) dragged = true; // a drag, not a tap
x.target = fingerX(e); showTouch(e);
});
// ---- the loop: runs only while something moves ----
let running = false, last = 0;
function wake() { if (!running) { running = true; last = performance.now(); requestAnimationFrame(frame); } }
function frame(now) {
const dt = Math.min(0.032, (now - last) / 1000); last = now;
x.step(dt);
// the drop stays down while it travels or while a finger holds it; it rises once it is nearly there
const left = Math.abs(x.target - x.value); // how far it still has to go
rise.target = pointer && dragged ? 0.1 : 1 - ease(left / (slot * 0.9)); // it rises as it closes in
rise.step(dt);
render();
if (pointer || !(x.resting(0.05) && rise.resting(0.002))) requestAnimationFrame(frame);
else running = false;
}
outline() does not change: when the drop is low, t is 1 and the
acos line keeps the neck above the surface, so the same function draws both the wave and the
standing drop. The icons stop riding along: each one stays in its column and lifts only when the drop is
over it and standing up.
function render() {
const r = rise.value;
const cy = LOW + (HIGH - LOW) * r;
const d = outline(x.value, cy, R);
shape.setAttribute('d', d); rim.setAttribute('d', d); // the fill and its contour: one path
// the icon rises out of the bar with the drop: it only lifts when the drop is right over it and standing up
icons.forEach((ic, k) => {
const near = ease(1 - Math.abs(centre(k) - x.value) / (slot * 0.75));
const lift = near * clamp(r * 1.25, 0, 1); // 0 = in the bar, 1 = in the drop
ic.style.transform = `translateY(${((cy - MID) * lift).toFixed(2)}px)`; // once lifted, it sits exactly at the drop's centre
});
}
4 · The landing: a splash and a label
When the drop arrives, four droplets fly up from where it lands. Each is a plain SVG circle with a starting
speed in px per second, pulled down by 900 px/s² of gravity. They live in a <g class="spray">
inside the same filled group as the bar, so they are the liquid's colour and vanish into it as they fall back
in; 24 px below the surface they are removed.
// ---- the splash: droplets thrown up from where the drop lands; they fall back into the surface ----
let spray = [];
function splash(cx) {
for (const [vx, vy, r] of [[-95, -230, 5.5], [15, -300, 6.5], [110, -210, 4.5], [-30, -170, 3.5]]) {
const el = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
el.setAttribute('r', r);
sprayLayer.appendChild(el);
spray.push({ el, x: cx + vx * 0.06, y: SURFACE - 2, vx, vy });
}
}
function stepSpray(dt) {
for (const d of spray) {
d.vy += 900 * dt; // gravity
d.x += d.vx * dt; d.y += d.vy * dt;
d.el.setAttribute('cx', d.x.toFixed(1)); d.el.setAttribute('cy', d.y.toFixed(1));
}
spray = spray.filter((d) => (d.y < SURFACE + 24 ? true : (d.el.remove(), false))); // back in the liquid
}
// the drop has arrived: splash, and write the label where the icon was, letter by letter
function arrive(i) {
if (shown === i) return;
if (shown !== -2) splash(x.value);
shown = i;
caption.innerHTML = [...tabs[i].querySelector('.label').textContent].map((c, k) => `<i style="--k2:${k}">${c}</i>`).join('');
}
shown remembers which tab's label is written, so the drop splashes once per arrival and not on
every frame. It is reset when the drop leaves or a finger drags it, and it starts at −2 so the first tab is
labelled without a splash when the page loads. The label is written on the surface where the icon was, one
<i> per letter, each delayed 30 ms more than the one before.
// in frame(): it has arrived when no finger holds it, it is close, and it is more than halfway up
const far = left > slot * 0.35;
// …
if (!pointer && !far && rise.value > 0.55) arrive(index);
if (far || (pointer && dragged)) shown = -1; // it has left: the next arrival writes the label again
// in render(): the label sits on the surface under the drop
caption.style.opacity = ease((r - 0.7) / 0.25) * (shown === index ? 1 : 0);
caption.style.left = `${x.value}px`; caption.style.top = `${MID + 1}px`;
.caption { position: absolute; top: 0; left: 0; margin: 0; transform: translate(-50%, -50%); font-size: 12.5px; font-weight: 800; color: var(--on-liq); white-space: nowrap; pointer-events: none; }
.caption i { display: inline-block; font-style: normal; animation: letter .45s cubic-bezier(.3, 1.5, .5, 1) both; animation-delay: calc(var(--k2) * 30ms); }
@keyframes letter { from { opacity: 0; transform: translateY(8px) scale(.6); } to { opacity: 1; transform: none; } }
5 · Light and colour
The drop gets a light of its own: an ellipse with an off-centre radial gradient, drawn over the liquid and under the contour. It fades in as the drop stands out of the surface and is fully on at rest.
<radialGradient id="drop-shade" cx=".38" cy=".3" r=".75">
<stop offset="0" stop-color="#fff" stop-opacity=".45"/><stop offset=".45" stop-color="#fff" stop-opacity="0"/>
<stop offset="1" stop-color="#000" stop-opacity=".07"/>
</radialGradient>
<!-- … -->
<ellipse class="drop-shade" cx="36" cy="44" rx="26" ry="26" fill="url(#drop-shade)"/> <!-- light on the drop -->
The icon that rides in the drop grows by 12% and mixes into the accent colour --lift as it
lifts. And as the low wave runs under the other icons, each one is nudged up by up to 6 px.
// the drop's own light, once it stands out of the liquid
shade.setAttribute('cx', x.value.toFixed(2)); shade.setAttribute('cy', cy.toFixed(2));
shade.style.opacity = ease((SURFACE - cy - 4) / 30);
// the icon itself rises out of the bar with the drop (and bobs a little as the wave passes under it)
icons.forEach((ic, k) => {
const near = ease(1 - Math.abs(centre(k) - x.value) / (slot * 0.75));
const lift = near * clamp(r * 1.25, 0, 1); // 0 = in the bar, 1 = in the drop
const wave = near * (1 - clamp(r, 0, 1)) * 6; // the passing wave nudges it up
const dy = (cy - MID) * lift - wave * (1 - lift); // once lifted, it sits exactly at the drop's centre
ic.style.transform = `translateY(${dy.toFixed(2)}px) scale(${(1 + 0.12 * lift).toFixed(3)})`;
ic.style.color = lift > 0.02 ? `color-mix(in srgb, var(--lift) ${Math.round(clamp(lift, 0, 1) * 100)}%, var(--on-liq))` : '';
});
6 · Palettes, reduced motion, and the drop letting go
The last stage is the finished file. The colours move into six palettes, chosen with
?palette= in the URL (pearl by default: try ?palette=cobalt). With
prefers-reduced-motion the springs are damped so nothing bounces, the splash and the squash are
off, and the letters and the touch circle appear without animating.
document.body.dataset.palette = new URLSearchParams(location.search).get('palette') || 'pearl';
const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
const SETTLE = calm ? { stiffness: 200, damping: 30 } : { stiffness: 190, damping: 24 };
const rise = new Spring(1, calm ? { stiffness: 200, damping: 30 } : { stiffness: 190, damping: 17 }); // one soft bounce
const squash = new Spring(0, { stiffness: 260, damping: 18 });
function splash(cx) {
if (calm) return;
// …
}
[data-palette="cobalt"] {
--bg: #1C33D9; --glow-a: rgba(120, 150, 255, .45); --glow-b: rgba(79, 109, 255, .40); --top: #2A47F2;
--liq-a: #FFFFFF; --liq-b: #EEF1FF; --on-liq: #1C33D9; --cut: #FFFFFF; --rim: rgba(255, 255, 255, .95);
--text: #FFFFFF; --dim: rgba(255, 255, 255, .72); --accent: linear-gradient(90deg, #FFE45C, #FFD23F); --accent-solid: #FFE45C;
--chip-text: #0E1A7A;
}
/* … pearl, mint, gold, coral and lime the same way */
@media (prefers-reduced-motion: reduce) { .touch { transition: none; } .caption i { animation: none; } }
It also handles a drop that jumps too high. If it rises more than NECK (22 px) above the
surface, outline() returns null: the bar is drawn as a plain capsule and the drop as an
ellipse of its own, which squashes wide when it moves fast and stretches tall when it rises. With the springs as
set, it never gets that far — rise peaks at about 1.07 and the drop would need 1.28 — but change
the rise damping from 17 to 10 and you will see it let go for a moment at the top of its bounce.
function outline(cx, cy, r) {
const gap = SURFACE - (cy + r);
if (gap > NECK) return null; // pinched off: two separate shapes
// …
}
// in render():
const sx = 1 + squash.value, sy = (1 / sx) * (1 + clamp(rise.velocity, 0, 8) * 0.025); // wide when fast, tall when rising
const joined = outline(x.value, cy, R);
const d = joined || CAPSULE;
shape.setAttribute('d', d); rim.setAttribute('d', d);
// once it has pinched off, the drop is its own shape (and may squash); while joined, it is part of the outline
for (const el of [drop, rimDrop, shade]) {
const on = el === shade || !joined;
el.setAttribute('cx', x.value.toFixed(2)); el.setAttribute('cy', cy.toFixed(2));
el.setAttribute('rx', on ? (R * (joined ? 1 : sx)).toFixed(2) : 0); el.setAttribute('ry', on ? (R * (joined ? 1 : sy)).toFixed(2) : 0);
}
Make it yours
- Colours are the palettes at the top of
style.css; add one and open the page with?palette=and its name. - Icons live in
icons.js, on a 24 × 24 grid; add one and give a button itsdata-icon. - The shape is a few constants in
app.js:Rfor the drop's size,HIGHfor how tall it stands,LOWfor the height of the wave. - The feel is
SETTLE,FOLLOWand therisespring. Lower the damping for more bounce, raise the stiffness for a quicker drop. - Accessibility: real buttons,
aria-selected, a hidden label on every tab, the arrow keys with one tab in the tab order, andprefers-reduced-motionas above. There are no tab panels here; when you add yours, link them witharia-controls.
When two shapes have to look like one,
draw them as one. A single computed outline gives you a crisp edge, a contour that follows the neck, and one
number, t, that takes the drop from a standing bead to a passing wave.