cd /news/developer-tools/a-liquid-glass-tab-bar-the-magnifyin… · home › topics › developer-tools › article
[ARTICLE · art-153949] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

A liquid glass tab bar: the magnifying lens is a scaled copy of the bar

A developer built a cross-browser liquid glass tab bar effect that reproduces Apple's magnifying lens without SVG displacement filters, which only render in Chromium. The lens is a clipped window onto a second, aria-hidden copy of the bar scaled 1.42x around the lens centre, so the icon under it appears magnified while the centre point stays fixed. The shipped component uses backdrop-filter blur with saturate(1.9) for the frosted glass and plain transforms for the magnification, and is offered as a single-file demo and reference for coding agents.

by read12 min views2 publishedOct 20, 2026

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:

backdrop-filter). 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.

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.

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:

overflow: hidden so you only see the part under it.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 };

.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:

drop-shadow s 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 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.

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:

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)).

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.

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:

prefers-reduced-motion: reduce it 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.transform-origin: 0 0 and put the whole offset in the translate.aria-hidden and give every button in it tabindex="-1". 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.

── more in #developer-tools 4 stories · sorted by recency
── more on @apple 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/a-liquid-glass-tab-b…] indexed:0 read:12min 2026-10-20 · —