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.
<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.
.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.
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);
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.
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.
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.
// 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.
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;
}
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.
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).
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();
});
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.
<div class="lens" aria-hidden="true"><div class="lens-view"></div></div>
// 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.
/* 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);
}
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: 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;
.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; }
}
Make it yours
- Colours are variables at the top of
style.css: change--accentand 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 itsdata-icon. - The feel is four numbers:
SETTLEandFOLLOW. Lower the damping for more bounce, raise the stiffness for a quicker lens. - Accessibility is already there: real buttons,
aria-selected, arrow keys, andprefers-reduced-motionturns the stretch, the swell and the icon moves off.
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.