A liquid glass tab bar: the magnifying lens is a scaled copy of the bar
Apple's liquid glass tab bars have a lens that sits on the selected tab and magnifies the icon under it. When you switch tabs it moves like a drop of water: it stretches on the way, then settles. Most web versions of th
Apple's liquid glass tab bars have a lens that sits on the selected tab and magnifies the icon under it. When you switch tabs it moves like a drop of water: it stretches on the way, then settles.
Most web versions of this effect use an SVG displacement filter inside backdrop-filter. That only renders in Chromium browsers; Safari and Firefox skip it. I built one for a music-app demo and wanted it to look the same in every browser, so the lens uses no filter at all. It's a small window onto a second copy of the bar, scaled up 1.42 times.
This post covers three pieces:
- The glass bar (plain
backdrop-filter). - The lens: a clipped, scaled copy, and the one line of math that keeps it lined up.
- The drop: a spring whose speed sets the lens width.
There's a single-file demo at the end you can paste into CodePen. If you build with Cursor or Claude Code, it also works as a reference file to hand to your agent: it's short, and every number in it is explained here.
1. The glass bar
The bar itself is ordinary frosted glass. Blur plus saturation does most of the work. The inset shadows draw a light top edge and a faint 1px rim so the bar reads as a solid object on any background.
This is the CSS from the shipped component (tabbar-liquid.css):
.glassbar {
position: absolute;
border-radius: 32px;
background: var(--glass);
-webkit-backdrop-filter: blur(16px) saturate(1.9);
backdrop-filter: blur(16px) saturate(1.9);
box-shadow:
inset 0 1px 0 rgba(255,255,255,.22),
inset 0 0 0 1px var(--glass-line),
0 18px 40px rgba(0,0,0,.45);
}
saturate(1.9) matters more than it looks. Blur alone washes colours out to grey. Saturation brings back the colour of whatever scrolls underneath, which is what makes it read as glass and not as a grey panel.
Keep the -webkit- prefix. Safari still wants it.
2. The lens is a copy, not a filter
Real magnification would need a displacement filter on whatever sits behind the lens. You can fake the part people actually notice, the icon under the lens getting bigger, with plain transforms:
- Render the tabs a second time inside the lens element.
- Give the lens
overflow: hiddenso you only see the part under it. - Scale the copy by
M(1.42 here) around the lens centre.
The markup is a lens with an inner layer that holds the copy:
<div class="lens" aria-hidden="true">
<div class="lensIn"><!-- the same tabs again, not focusable --></div>
</div>
The copy must stay out of the accessibility tree and out of the tab order, or a screen reader reads every tab twice. In the React component the copy is rendered with live={false}, which does exactly that:
const inert = live ? {} : { tabIndex: -1, 'aria-hidden': true as const };
The one line of math
.lensIn has transform-origin: 0 0 and fills the same box as the real bar. The lens has width w, height h, and centre (x, y) in bar coordinates, so its top-left corner is (lx, ly) = (x - w/2, y - h/2).
You want the point under the lens centre to stay where it is, with everything around it pushed outwards by M. Here's the render code from the shipped engine.ts:
const Ls = S.lens, lx = Ls.x - Ls.w / 2, ly = Ls.y - Ls.h / 2, M = 1.42;
lens.style.transform = `translate(${lx.toFixed(1)}px,${ly.toFixed(1)}px) scale(${Ls.sx.toFixed(4)},${Ls.sy.toFixed(4)})`;
lensIn.style.transform = `translate(${(-lx + Ls.x - M * Ls.x).toFixed(2)}px,${(-ly + Ls.y - M * Ls.y).toFixed(2)}px) scale(${M})`;
Why it works: a point p of the copy lands at Mยทp + t inside the lens, and the lens sits at lx. On screen that is lx + Mยทp + (-lx + x - Mยทx), which simplifies to x + Mยท(p - x). At p = x you get x, so the centre stays put and everything else spreads out from it by M. It's the textbook "scale around a point" transform, with the lens offset cancelled out.
Since the copy is real DOM, you can style it apart from the bar. Trimmed from the shipped CSS (the comments are mine):
.lens {
position: absolute; left: 0; top: 0; overflow: hidden; pointer-events: none;
background: rgba(255,255,255,.07);
backdrop-filter: blur(3px) saturate(2) brightness(1.18);
box-shadow:
inset 0 2px 0 rgba(255,255,255,.75), /* bright top rim */
inset 0 -2px 0 rgba(198,244,50,.55), /* lime bottom rim */
inset 0 -12px 20px rgba(139,108,255,.4),
inset 5px 0 9px rgba(255,90,170,.35), /* pink left edge */
inset -5px 0 9px rgba(90,220,255,.35), /* cyan right edge */
0 0 0 1.5px rgba(255,255,255,.4),
0 12px 28px rgba(0,0,0,.5),
0 0 34px rgba(198,244,50,.42);
}
.lensIn { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
.lensIn .tab { color: var(--volt); } /* the magnified icon turns lime */
.lensIn .tab i { opacity: 0 !important; } /* hide the label inside the lens */
.lensIn .tab svg { translate: 0 8px; } /* and drop the icon into the middle */
.lensIn .dockc {
filter: drop-shadow(1.6px 0 0 rgba(255,70,150,.6))
drop-shadow(-1.6px 0 0 rgba(70,210,255,.6));
}
Two details from that block:
- The pink and cyan inset shadows on the left and right edges, plus two 1.6px
drop-shadows on the copy, fake the colour fringing you see where real glass bends light. They're cheap and they sell the effect more than anything else in the file. - The label is hidden inside the lens and the icon moves down 8px. A magnified label gets cut off by the lens edge and looks like a bug. A centred icon looks deliberate.
The cost of this approach is that the content renders twice. For five icons that's nothing. The same component reuses the trick for a bigger orb that lifts out of the bar: the orb's copy holds the whole screen, scaled with the same formula.
3. The drop: speed sets the width
A lens that slides at a fixed width looks like a highlight pill. It reads as liquid once it stretches while it moves and goes back to round when it stops.
In the React version the lens x-position is a Framer Motion spring, and useVelocity reads how fast that spring is moving:
// live mode: the lens rides a spring, and its speed stretches the drop
const x = useSpring(tabX(0), { stiffness: 380, damping: 26 });
const stretch = useVelocity(x);
Every frame, the speed (px/s) maps to the lens size:
const v = Math.abs(stretch.get());
S.lens = {
x: lx, y: g.cy,
w: mix(66 + Math.min(92, v / 9), 56, c),
h: mix(52 - Math.min(10, v / 90), 56, c),
/* ... */
};
Leave out mix(..., c) for now. That's the compact state, when the bar shrinks on scroll. What's left:
- At rest the lens is 66 ร 52.
- It gets 1px wider for every 9 px/s of speed, up to 92px more.
- It gets thinner by up to 10px, so a fast move reads as a long, thin drop.
- The clamps stop a very fast flick from stretching it across the whole bar.
The spring's overshoot does the rest. With stiffness 380 and damping 26 the spring is underdamped (damping ratio about 0.67), so it passes the target a little and comes back. The width follows the speed, so you get a wobble on landing without writing one keyframe.
Switching tabs is one line: x.set(tabX(i)).
Keyboard and screen readers
The real tabs are a tablist. Each tab gets role="tab" and aria-selected, and left/right arrows move between them:
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
e.preventDefault(); i = (i + (e.key === 'ArrowRight' ? 1 : 4)) % 5;
screen.current?.querySelectorAll<HTMLElement>('[data-dock] [data-tab]')[i]?.focus();
(+ 4 is - 1 modulo 5, so left arrow from the first tab wraps to the last.)
The lens, the copy and the rings are all aria-hidden. Only the real tabs are announced.
The single-file version
No Framer Motion here. The spring is about ten lines of Hooke's law with damping, using the same stiffness and damping as the React version. Paste it into CodePen's HTML panel or save it as a .html file and open it:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Liquid glass tab bar: magnifying lens demo</title>
<!-- MIT licence. Teaching version of the lens from qodesign's Liquid Glass Tab Bar (qodesign.dev). -->
<style>
:root { --ink:#0A0A0D; --text:#F4F4F6; --muted:#8A8FA3; --lime:#C6FF3D; }
* { box-sizing:border-box; margin:0 }
body { min-height:100vh; display:grid; place-items:center; background:#050507; font-family:system-ui,sans-serif; color:var(--text) }
.phone { position:relative; width:390px; height:700px; max-width:100vw; border-radius:44px; overflow:hidden; background:var(--ink) }
.feed { position:absolute; inset:0; overflow-y:auto; padding:56px 18px 140px; display:grid; gap:14px }
.card { height:150px; border-radius:22px; display:flex; align-items:flex-end; padding:16px; font-weight:600 }
.card:nth-child(3n+1) { background:linear-gradient(135deg,#8B6CFF,#2a1d66) }
.card:nth-child(3n+2) { background:linear-gradient(135deg,#ff6a3d,#5a1d0b) }
.card:nth-child(3n) { background:linear-gradient(135deg,#C6FF3D,#2d4a00); color:#0A0A0D }
/* the glass bar */
.dock { position:absolute; left:14px; right:14px; bottom:22px; height:76px }
.bar { position:absolute; inset:0; border-radius:32px; background:rgba(28,30,40,.45);
-webkit-backdrop-filter:blur(16px) saturate(1.9); backdrop-filter:blur(16px) saturate(1.9);
box-shadow:inset 0 1px 0 rgba(255,255,255,.22), inset 0 0 0 1px rgba(255,255,255,.1), 0 18px 40px rgba(0,0,0,.45) }
.tabs { position:absolute; inset:0; display:flex }
.tab { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:3px;
border:0; background:none; color:var(--muted); font:600 10.5px system-ui; border-radius:24px; cursor:pointer; touch-action:none }
.tab svg { width:25px; height:25px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round }
.tab[aria-selected="true"] { color:var(--text) }
.tab:focus-visible { outline:2px solid var(--lime); outline-offset:-4px }
/* the lens: a window onto a scaled copy of the tabs */
.lens { position:absolute; left:0; top:0; overflow:hidden; pointer-events:none; will-change:transform,width,height;
background:rgba(255,255,255,.07);
-webkit-backdrop-filter:blur(3px) saturate(2) brightness(1.18); backdrop-filter:blur(3px) saturate(2) brightness(1.18);
box-shadow:inset 0 2px 0 rgba(255,255,255,.75), inset 0 -2px 0 rgba(198,255,61,.55), inset 0 -12px 20px rgba(139,108,255,.4),
inset 5px 0 9px rgba(255,90,170,.35), inset -5px 0 9px rgba(90,220,255,.35), 0 0 0 1.5px rgba(255,255,255,.4), 0 0 34px rgba(198,255,61,.42) }
.lens::after { content:""; position:absolute; left:14%; right:14%; top:5%; height:34%; border-radius:50%;
background:linear-gradient(rgba(255,255,255,.5),rgba(255,255,255,0)) }
.lensIn { position:absolute; left:0; top:0; transform-origin:0 0;
filter:drop-shadow(1.6px 0 0 rgba(255,70,150,.6)) drop-shadow(-1.6px 0 0 rgba(70,210,255,.6)) }
.lensIn .tab { color:var(--lime) }
.lensIn .tab span { opacity:0 }
.lensIn .tab svg { translate:0 8px }
</style>
</head>
<body>
<div class="phone">
<div class="feed" aria-label="Feed">
<div class="card">Daily mix</div><div class="card">Late night</div><div class="card">Focus</div>
<div class="card">Running</div><div class="card">Vinyl finds</div><div class="card">Live sets</div>
</div>
<nav class="dock">
<div class="bar"></div>
<div class="tabs" role="tablist" aria-label="Sections" id="tabs"></div>
<div class="lens" id="lens" aria-hidden="true"><div class="lensIn" id="lensIn"></div></div>
</nav>
</div>
<script>
const TABS = [
['Home', '<path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/>'],
['Explore', '<circle cx="12" cy="12" r="9"/><path d="M15.5 8.5l-2 5-5 2 2-5z"/>'],
['Library', '<path d="M4 4v16M9 4v16M14 4l6 16"/>'],
['Inbox', '<path d="M4 13l2-8h12l2 8v6H4z"/><path d="M4 13h5l1 2h4l1-2h5"/>'],
['Profile', '<circle cx="12" cy="8" r="4"/><path d="M4 21c1.5-4 4.5-6 8-6s6.5 2 8 6"/>'],
];
const tabHTML = (live) => TABS.map(([label, icon], i) =>
`<button class="tab" type="button" ${live ? `role="tab" aria-selected="${i === 0}" tabindex="${i ? -1 : 0}"` : 'tabindex="-1"'}>
<svg viewBox="0 0 24 24" aria-hidden="true">${icon}</svg><span>${label}</span></button>`).join('');
const $ = (id) => document.getElementById(id);
const dock = document.querySelector('.dock'), tabsEl = $('tabs'), lens = $('lens'), lensIn = $('lensIn');
tabsEl.innerHTML = tabHTML(true);
lensIn.innerHTML = `<div class="tabs">${tabHTML(false)}</div>`; // the copy the lens magnifies
const tabs = [...tabsEl.children];
const M = 1.42; // magnification
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
// a tiny spring: same feel as Framer Motion's useSpring({ stiffness: 380, damping: 26 })
const spring = { x: 0, v: 0, target: 0, k: 380, c: 26 };
const centerOf = (i) => tabs[i].offsetLeft + tabs[i].offsetWidth / 2;
let active = 0;
function select(i, focus) {
active = (i + tabs.length) % tabs.length;
tabs.forEach((t, j) => { t.setAttribute('aria-selected', j === active); t.tabIndex = j === active ? 0 : -1; });
if (focus) tabs[active].focus();
spring.target = centerOf(active);
if (reduce.matches) { spring.x = spring.target; spring.v = 0; }
}
function paint() {
const W = dock.clientWidth, H = dock.clientHeight;
lensIn.style.width = W + 'px'; lensIn.style.height = H + 'px';
const speed = Math.abs(spring.v); // px per second
const w = 66 + Math.min(92, speed / 9); // faster = longer drop
const h = 52 - Math.min(10, speed / 90); // ...and a little thinner
const x = Math.max(w / 2, Math.min(W - w / 2, spring.x)); // keep the drop inside the bar
const y = H / 2, lx = x - w / 2, ly = y - h / 2;
lens.style.width = w + 'px'; lens.style.height = h + 'px';
lens.style.borderRadius = Math.min(w, h) / 2 + 'px';
lens.style.transform = `translate(${lx}px, ${ly}px)`;
// keep the point under the lens centre still, and scale everything around it
lensIn.style.transform = `translate(${-lx + x - M * x}px, ${-ly + y - M * y}px) scale(${M})`;
}
let last = performance.now();
function frame(now) {
const dt = Math.min(0.032, (now - last) / 1000); last = now;
const a = -spring.k * (spring.x - spring.target) - spring.c * spring.v; // Hooke + damping, mass 1
spring.v += a * dt; spring.x += spring.v * dt;
paint();
requestAnimationFrame(frame);
}
// tap, drag across the bar, or use the arrow keys
const nearest = (clientX) => {
const r = dock.getBoundingClientRect(), px = clientX - r.left;
return tabs.reduce((best, t, i) => Math.abs(centerOf(i) - px) < Math.abs(centerOf(best) - px) ? i : best, 0);
};
tabsEl.addEventListener('pointerdown', (e) => { tabsEl.setPointerCapture(e.pointerId); select(nearest(e.clientX)); });
tabsEl.addEventListener('pointermove', (e) => { if (e.buttons) select(nearest(e.clientX)); });
tabsEl.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight') { e.preventDefault(); select(active + 1, true); }
if (e.key === 'ArrowLeft') { e.preventDefault(); select(active - 1, true); }
});
addEventListener('resize', () => { spring.target = spring.x = centerOf(active); });
spring.x = spring.target = centerOf(0);
requestAnimationFrame(frame);
</script>
</body>
</html>
The parts worth reading in the demo:
// a tiny spring: same feel as Framer Motion's useSpring({ stiffness: 380, damping: 26 })
const a = -spring.k * (spring.x - spring.target) - spring.c * spring.v;
spring.v += a * dt; spring.x += spring.v * dt;
const speed = Math.abs(spring.v); // px per second
const w = 66 + Math.min(92, speed / 9); // faster = longer drop
const h = 52 - Math.min(10, speed / 90); // ...and a little thinner
const x = Math.max(w / 2, Math.min(W - w / 2, spring.x)); // keep the drop inside the bar
lensIn.style.transform = `translate(${-lx + x - M * x}px, ${-ly + y - M * y}px) scale(${M})`;
The demo adds three things the shipped component doesn't have:
- Drag along the bar and the lens follows your finger to the nearest tab, stretching over every tab it passes.
- It clamps the drop inside the bar, because a 158px drop on the last tab hangs off the edge.
- With
prefers-reduced-motion: reduceit jumps straight to the new tab, with no stretch and no wobble. Do that in anything you ship. A drop that stretches across the screen is exactly the kind of motion that setting is for.
Easy ways to get it wrong
-
Scaling the copy around its own centre. The magnified icon drifts sideways as the lens moves. Use
transform-origin: 0 0and put the whole offset in the translate. - A CSS transition on the lens width. The width then lags behind the position. Set width, height and transform in the same frame, all read from the spring.
-
A focusable copy. Keyboard users tab through ten tabs instead of five. Mark the copy
aria-hiddenand give every button in ittabindex="-1". - Labels inside the lens. They get cut by the lens edge. Hide them in the copy.
About me, and the links. I'm Teodor, and I make animated UI components at qodesign. The tab bar in this post is one of them, so this is partly a post about my own product. The demo above is MIT: copy it, change it, ship it. The full component adds the orb that turns into a full-screen player, the compact bar on scroll, the badge pop, a React + TypeScript + Tailwind + Framer Motion version, and a PROMPT.md for AI coding tools. It's $3 on qodesign.dev. This week I'm also putting a few components on GitHub for free (MIT): two dashboards, a toast stack, a pricing switch and an upload dropzone. I'll add the link here when the repo is public.
If you build a lens with a real displacement map that works in Safari, I'd like to see it.
I wrote this post with help from an AI assistant. The code excerpts are copied from the shipped component, and the demo was run and checked in a browser before publishing.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.