Library / Tutorial

Drop tabs,
step by step.

A tab bar whose chosen tab is a drop of the bar itself. Choose another tab and the drop sinks into the surface, runs along it as a wave, rises at the new tab and splashes, and the tab's name is written where its icon was. We build it in six stages, and every stage runs on its own — try each one before you read the next.

6Stages
1Path for bar and drop
0Libraries

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.

index.html
<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.

style.css — the bar
.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.

app.js — the geometry
// ---- 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.

app.js — one outline for the bar and the drop
// ---- 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.

app.js — select and draw
// ---- 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);
Stage 1 · tap a tabOpen ↗

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.

app.js — press, drag, let go
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.

app.js — arrow keys, and the icon in the drop
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)` : ''; });
Stage 2 · drag the dropOpen ↗

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.

spring.js — 30 lines
// 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.

app.js — two springs and a loop
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.

app.js — draw at the spring values
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
  });
}
Stage 3 · watch it sink and riseOpen ↗

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.

app.js — the splash and the arrival
// ---- 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.

app.js — when it has arrived
// 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`;
style.css — the letters
.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; } }
Stage 4 · the splashOpen ↗

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.

index.html — the light on the drop
<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.

app.js — in render()
// 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))` : '';
});
Stage 5 · light on the dropOpen ↗

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.

app.js — palette and reduced motion
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;
  // …
}
style.css — a palette, and reduced motion
[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.

app.js — the drop lets go
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);
}
Stage 6 · the finished barOpen ↗

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 its data-icon.
  • The shape is a few constants in app.js: R for the drop's size, HIGH for how tall it stands, LOW for the height of the wave.
  • The feel is SETTLE, FOLLOW and the rise spring. 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, and prefers-reduced-motion as above. There are no tab panels here; when you add yours, link them with aria-controls.
The one idea worth keeping

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.