Library / Tutorial

Liquid tabs,
step by step.

A tab bar with a glass lens in it. Press anywhere and the lens comes to your finger, stretches when you move fast, magnifies the icons under it and settles on the nearest tab with one soft bounce. We build it in six stages, and every stage runs on its own — try each one before you read the next.

6Stages
30Lines of spring
0Libraries

This is the whole build of Liquid tabs, in the order it was written. Each stage below is a live page: press, drag and let go on it. The code under each heading is only what that stage adds; the finished source is at the end.

Three files do the work: index.html for the markup, style.css for the look and app.js for the motion. From stage 3 there is a fourth, spring.js, and at stage 6 a fifth, icons.js.

1 · A real tab bar

Start with buttons, not with the effect. Five <button>s in a role="tablist" give you focus, keyboard and a screen-reader announcement for free. The lens is one extra element inside the bar, hidden from assistive technology because it is only decoration.

index.html
<nav class="tabbar" role="tablist" aria-label="Main">
  <button class="tab" role="tab" aria-label="Home">
    <svg viewBox="0 0 24 24">…</svg>
    <span class="label">Home</span>
  </button>
  <!-- Profile, Messages, Photos and Settings: the same button -->

  <!-- the lens: one glass capsule that sits on the selected tab -->
  <div class="lens" aria-hidden="true"></div>
</nav>

The bar is a five-column grid with a padding; the lens is positioned absolutely inside it. The only CSS the motion will need later is will-change on the lens.

style.css — the bar and the lens
.tabbar {
  position: relative; touch-action: none;
  width: var(--bar-w); height: var(--bar-h);
  display: grid; grid-template-columns: repeat(var(--tabs), 1fr);
  padding: var(--pad); border-radius: 999px;
  background: linear-gradient(180deg, rgba(255, 255, 255, .075), rgba(255, 255, 255, .03));
  backdrop-filter: blur(18px) saturate(1.3);
  border: 1px solid rgba(255, 255, 255, .09);
}
.lens {
  position: absolute; top: 0; left: 0; z-index: 2; pointer-events: none;
  border-radius: 999px; overflow: hidden;
  will-change: transform, width, height;
}

For now the lens just jumps: a tab's slot is the bar's inner width divided by five, and the lens is moved onto slot index with a transform.

app.js — the whole of stage 1
const bar = document.querySelector('.tabbar');
const tabs = [...document.querySelectorAll('.tab')];
const lens = document.querySelector('.lens');

function select(index) {
  const pad = parseFloat(getComputedStyle(bar).paddingLeft);
  const slot = (bar.clientWidth - 2 * pad) / tabs.length;
  tabs.forEach((tab, i) => tab.setAttribute('aria-selected', String(i === index)));
  lens.style.width = `${slot}px`;
  lens.style.height = `${bar.clientHeight - 2 * pad}px`;
  lens.style.transform = `translate(${pad + index * slot}px, ${pad}px)`;
}

tabs.forEach((tab, i) => tab.addEventListener('click', () => select(i)));
select(0);
Stage 1 · tap a tabOpen ↗

2 · The lens follows the finger

Pointer events cover a mouse, a finger and a pen with one set of listeners. setPointerCapture is the important line: once the bar has captured the pointer, it keeps receiving moves even when the finger slides off it, so a drag never gets stuck halfway.

app.js — press, drag, let go
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const slotLeft = (i) => i * slot;
const nearest = (x) => clamp(Math.round(x / slot), 0, tabs.length - 1);

let pointer = null;
function fingerX(e) {
  return clamp(e.clientX - bar.getBoundingClientRect().left - pad - slot / 2, 0, slotLeft(tabs.length - 1));
}

bar.addEventListener('pointerdown', (e) => {
  bar.setPointerCapture(e.pointerId);   // keep getting moves even when the finger leaves the bar
  pointer = e.pointerId;
  x = fingerX(e); place();
});
bar.addEventListener('pointermove', (e) => {
  if (e.pointerId !== pointer) return;
  x = fingerX(e); place();
});
function release(e) {
  if (e.pointerId !== pointer) return;
  pointer = null;
  select(nearest(x));
}
bar.addEventListener('pointerup', release);
bar.addEventListener('pointercancel', release);

Letting go rounds the position to the nearest tab. It works, but everything still teleports — which is what the next stage is for.

Stage 2 · drag the lensOpen ↗

3 · A spring, from scratch

Every motion in this component comes from one small class. 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. That is all an "ease" really is here — and unlike a CSS transition, a spring can change its target 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 settings do two jobs: a stiff, heavily damped FOLLOW keeps the lens right under the finger, and a softer SETTLE takes over on release, keeps the lens's speed and lands it with one overshoot. The animation loop runs only while something is moving.

app.js — the spring drives the lens
const SETTLE = { stiffness: 520, damping: 26 };   // one soft overshoot
const FOLLOW = { stiffness: 1100, damping: 64 };  // stays under the finger
const x = new Spring(0, SETTLE);

// pressing switches to FOLLOW, letting go switches back to SETTLE and aims at the nearest tab
bar.addEventListener('pointerdown', (e) => { /* … */ x.set(FOLLOW).target = fingerX(e); wake(); });
bar.addEventListener('pointermove', (e) => { /* … */ x.target = fingerX(e); });

// one animation loop, running 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);
  lens.style.transform = `translate(${pad + x.value}px, ${pad}px)`;
  if (pointer || !x.resting(0.05)) requestAnimationFrame(frame); else running = false;
}
Stage 3 · fling itOpen ↗

4 · Speed changes the material

Two more springs turn a sliding box into something that feels liquid. stretch follows the lens's speed: the faster it moves, the longer and thinner it gets. press is 1 while a finger is down, so the lens swells out of the bar like a drop you are holding.

app.js — stretch and press
const stretch = new Spring(0, { stiffness: 500, damping: 28 });
const press = new Spring(0, { stiffness: 700, damping: 40 });

// in frame(): the faster the lens moves, the more it stretches
const speed = Math.min(Math.abs(x.velocity) / 1200, 1);
stretch.target = calm ? 0 : 0.24 * speed;

function render() {
  const s = stretch.value, p = press.value;
  const grow = 1 + 0.18 * p;                                  // pressed: the lens swells out of the bar
  const w = lensW * (1 + s) * grow;
  const h = (lensH / (1 + 0.55 * s)) * grow;                  // stretched: a little thinner, like liquid
  const tail = calm ? 0 : clamp(-0.006 * x.velocity, -7, 7);  // the back of the drop lags a little
  const cx = pad + x.value + slot / 2 + tail;
  const left = cx - w / 2, top = (barH - h) / 2;
  lens.style.width = `${w}px`;
  lens.style.height = `${h}px`;
  lens.style.transform = `translate(${left}px, ${top}px)`;
}

Because the lens is now moved by code, the keyboard needs its own path. The arrow keys move the selection, and only the selected tab is in the tab order (tabIndex 0, the others −1).

app.js — arrow keys
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();
});
Stage 4 · move fast, then slowOpen ↗

5 · Glass that magnifies

Real refraction would need WebGL. The trick here is cheaper and looks the same at this size: the lens holds a second copy of the whole bar, laid out exactly like the real one, and that copy is shifted and scaled around the lens's centre. Where the lens is, you see the copy — larger, and with the label that the real bar does not show.

index.html — the lens gets a view
<div class="lens" aria-hidden="true"><div class="lens-view"></div></div>
app.js — build the copy, line it up, magnify
// the magnified copy inside the lens: every tab again, now with its label
view.innerHTML = tabs.map((tab) =>
  `<div class="ghost">${tab.querySelector('svg').outerHTML}<span>${tab.querySelector('.label').textContent}</span></div>`).join('');

// in render(): the copy lines up with the real bar, then is magnified around the lens centre
const magnify = 1.14 + 0.16 * p;
view.style.transformOrigin = `${cc}px ${barH / 2}px`;
view.style.transform = `translate(${cx - cc - left}px, ${-top}px) scale(${magnify})`;

// the real icons fade out as the lens reaches them, so nothing is ever drawn twice
icons.forEach((icon, k) => {
  const d = Math.abs(pad + slot * (k + 0.5) - cx) - w / 2;   // gap between the icon centre and the lens edge
  icon.style.opacity = Math.min(1, Math.max(0, (d + 6) / 16));
});

The glass itself is mostly its rim: a nearly transparent centre, a bright edge, a thin highlight on top and a backdrop-filter that lifts what is behind it.

style.css — the glass
/* clear, thick glass: the middle is almost invisible; the rim is where glass shows */
.lens {
  background:
    radial-gradient(closest-side, rgba(255, 255, 255, 0) 58%, rgba(255, 255, 255, .07) 80%, rgba(255, 255, 255, .22) 100%),
    linear-gradient(180deg, rgba(255, 255, 255, .10), rgba(255, 255, 255, .02) 45%, rgba(255, 107, 53, .10));
  backdrop-filter: brightness(1.12) saturate(1.5);
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, .30),
    inset 0 2px 5px rgba(255, 255, 255, .30),
    inset 0 -3px 8px rgba(255, 107, 53, .22),
    0 12px 28px -10px rgba(255, 107, 53, .45);
}
.lens-view {       /* a copy of the whole bar, laid out exactly like it, then magnified */
  position: absolute; top: 0; left: 0;
  display: grid; grid-template-columns: repeat(var(--tabs), 1fr);
  padding: var(--pad);
}
Stage 5 · the glass lensOpen ↗

6 · The icons come alive

The last stage is polish. Each icon is drawn twice — a line version and a filled one — so the chosen icon can fill with colour from the bottom up. Then it pops, does one small move of its own (the house squashes, the head nods, the dots type, the sun rises, the gear turns), and its label rises letter by letter.

icons.js + app.js
// icons.js: each icon has a line version (at rest) and a filled version (selected), stacked
function icon(name) {
  const i = ICONS[name];
  return `<svg class="icon icon-${name}" viewBox="0 0 24 24" aria-hidden="true"><g class="fill">${i.fill}</g><g class="line">${i.line}</g></svg>`;
}

// app.js: the label inside the lens gets one span per letter, so the letters can rise one by one
view.innerHTML = tabs.map((tab) => {
  const letters = [...tab.querySelector('.label').textContent].map((c, k) => `<i style="--k:${k}">${c}</i>`).join('');
  return `<div class="ghost">${icon(tab.dataset.icon)}<span>${letters}</span></div>`;
}).join('');

// in select(): restart the chosen icon's animation — take the class off, let the browser see it, put it back
ghosts.forEach((g) => g.classList.remove('is-on'));
void view.offsetWidth;
ghosts[i].classList.add('is-on');

// in release(): the landing — a soft splat, wider and lower for a moment
if (!calm) stretch.velocity += 1.6;
style.css — the moves
.ghost.is-on .icon { animation: pop .62s cubic-bezier(.28, 1.6, .5, 1) both; }
.ghost.is-on .icon .fill { opacity: 1; animation: pour .55s cubic-bezier(.3, .8, .3, 1) both; }
.ghost.is-on i { animation: rise .5s cubic-bezier(.25, 1.4, .5, 1) both; animation-delay: calc(.12s + var(--k) * 32ms); }
@keyframes pop  { 0% { transform: scale(.72); } 55% { transform: scale(1.14); } 100% { transform: scale(1); } }
@keyframes pour { from { clip-path: inset(100% 0 0 0); } to { clip-path: inset(0 0 0 0); } }
@keyframes rise { from { opacity: 0; transform: translateY(7px) scale(.8); filter: blur(2px); } to { opacity: 1; transform: none; filter: none; } }

/* and one small move of its own per icon */
.ghost.is-on .icon-home { transform-origin: 50% 90%; animation: squash .7s cubic-bezier(.3, 1.5, .5, 1) both; }
.ghost.is-on .icon-profile .head { animation: nod .7s .12s cubic-bezier(.3, 1.6, .5, 1) both; }
.ghost.is-on .icon-settings .gear { animation: turn .9s cubic-bezier(.3, 1.3, .5, 1) both; }
@keyframes squash { 0% { transform: scale(.8, .8); } 30% { transform: scale(1.22, .78); } 60% { transform: scale(.92, 1.12); } 100% { transform: scale(1); } }
@keyframes nod { 0% { transform: translateY(2.5px); } 45% { transform: translateY(-2px); } 100% { transform: none; } }
@keyframes turn { from { transform: rotate(-90deg); } to { transform: rotate(0deg); } }

@media (prefers-reduced-motion: reduce) {
  .ghost.is-on .icon, .ghost.is-on .icon *, .ghost.is-on i { animation: none !important; }
}
Stage 6 · the finished barOpen ↗

Make it yours

  • Colours are variables at the top of style.css: change --accent and the lens, the glow and the filled icons follow.
  • Icons live in icons.js, on a 24 × 24 grid; add one and give a button its data-icon.
  • The feel is four numbers: SETTLE and FOLLOW. Lower the damping for more bounce, raise the stiffness for a quicker lens.
  • Accessibility is already there: real buttons, aria-selected, arrow keys, and prefers-reduced-motion turns the stretch, the swell and the icon moves off.
The one idea worth keeping

Build the plain, working control first and add the feel in layers. Every stage above is usable on its own, so when something looks wrong you know exactly which layer to open.