{"slug": "a-liquid-glass-tab-bar-the-magnifying-lens-is-a-scaled-copy-of-the-bar", "title": "A liquid glass tab bar: the magnifying lens is a scaled copy of the bar", "summary": "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.", "body_md": "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.\n\nMost 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.\n\nThis post covers three pieces:\n\n`backdrop-filter`).\nThere'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.\n\nThe 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.\n\nThis is the CSS from the shipped component (`tabbar-liquid.css`):\n\n```\n.glassbar {\n  position: absolute;\n  border-radius: 32px;\n  background: var(--glass);\n  -webkit-backdrop-filter: blur(16px) saturate(1.9);\n  backdrop-filter: blur(16px) saturate(1.9);\n  box-shadow:\n    inset 0 1px 0 rgba(255,255,255,.22),\n    inset 0 0 0 1px var(--glass-line),\n    0 18px 40px rgba(0,0,0,.45);\n}\n```\n\n`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.\n\nKeep the `-webkit-` prefix. Safari still wants it.\n\nReal 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:\n\n`overflow: hidden` so you only see the part under it.`M` (1.42 here) around the lens centre.\nThe markup is a lens with an inner layer that holds the copy:\n\n```\n<div class=\"lens\" aria-hidden=\"true\">\n  <div class=\"lensIn\"><!-- the same tabs again, not focusable --></div>\n</div>\n```\n\nThe 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:\n\n``` js\nconst inert = live ? {} : { tabIndex: -1, 'aria-hidden': true as const };\n```\n\n`.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)`.\n\nYou 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`:\n\n``` js\nconst Ls = S.lens, lx = Ls.x - Ls.w / 2, ly = Ls.y - Ls.h / 2, M = 1.42;\nlens.style.transform = `translate(${lx.toFixed(1)}px,${ly.toFixed(1)}px) scale(${Ls.sx.toFixed(4)},${Ls.sy.toFixed(4)})`;\nlensIn.style.transform = `translate(${(-lx + Ls.x - M * Ls.x).toFixed(2)}px,${(-ly + Ls.y - M * Ls.y).toFixed(2)}px) scale(${M})`;\n```\n\nWhy 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.\n\nSince the copy is real DOM, you can style it apart from the bar. Trimmed from the shipped CSS (the comments are mine):\n\n```\n.lens {\n  position: absolute; left: 0; top: 0; overflow: hidden; pointer-events: none;\n  background: rgba(255,255,255,.07);\n  backdrop-filter: blur(3px) saturate(2) brightness(1.18);\n  box-shadow:\n    inset 0 2px 0 rgba(255,255,255,.75),     /* bright top rim */\n    inset 0 -2px 0 rgba(198,244,50,.55),     /* lime bottom rim */\n    inset 0 -12px 20px rgba(139,108,255,.4),\n    inset 5px 0 9px rgba(255,90,170,.35),    /* pink left edge */\n    inset -5px 0 9px rgba(90,220,255,.35),   /* cyan right edge */\n    0 0 0 1.5px rgba(255,255,255,.4),\n    0 12px 28px rgba(0,0,0,.5),\n    0 0 34px rgba(198,244,50,.42);\n}\n.lensIn { position: absolute; left: 0; top: 0; transform-origin: 0 0; }\n.lensIn .tab { color: var(--volt); }        /* the magnified icon turns lime */\n.lensIn .tab i { opacity: 0 !important; }   /* hide the label inside the lens */\n.lensIn .tab svg { translate: 0 8px; }      /* and drop the icon into the middle */\n.lensIn .dockc {\n  filter: drop-shadow(1.6px 0 0 rgba(255,70,150,.6))\n          drop-shadow(-1.6px 0 0 rgba(70,210,255,.6));\n}\n```\n\nTwo details from that block:\n\n`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.\nThe 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.\n\nA 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.\n\nIn the React version the lens x-position is a Framer Motion spring, and `useVelocity` reads how fast that spring is moving:\n\n``` js\n// live mode: the lens rides a spring, and its speed stretches the drop\nconst x = useSpring(tabX(0), { stiffness: 380, damping: 26 });\nconst stretch = useVelocity(x);\n```\n\nEvery frame, the speed (px/s) maps to the lens size:\n\n``` js\nconst v = Math.abs(stretch.get());\nS.lens = {\n  x: lx, y: g.cy,\n  w: mix(66 + Math.min(92, v / 9), 56, c),\n  h: mix(52 - Math.min(10, v / 90), 56, c),\n  /* ... */\n};\n```\n\nLeave out `mix(..., c)` for now. That's the compact state, when the bar shrinks on scroll. What's left:\n\nThe 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.\n\nSwitching tabs is one line: `x.set(tabX(i))`.\n\nThe real tabs are a `tablist`. Each tab gets `role=\"tab\"` and `aria-selected`, and left/right arrows move between them:\n\n```\nif (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;\ne.preventDefault(); i = (i + (e.key === 'ArrowRight' ? 1 : 4)) % 5;\nscreen.current?.querySelectorAll<HTMLElement>('[data-dock] [data-tab]')[i]?.focus();\n```\n\n(`+ 4` is `- 1` modulo 5, so left arrow from the first tab wraps to the last.)\n\nThe lens, the copy and the rings are all `aria-hidden`. Only the real tabs are announced.\n\nNo 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:\n\n```\n<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>Liquid glass tab bar: magnifying lens demo</title>\n<!-- MIT licence. Teaching version of the lens from qodesign's Liquid Glass Tab Bar (qodesign.dev). -->\n<style>\n  :root { --ink:#0A0A0D; --text:#F4F4F6; --muted:#8A8FA3; --lime:#C6FF3D; }\n  * { box-sizing:border-box; margin:0 }\n  body { min-height:100vh; display:grid; place-items:center; background:#050507; font-family:system-ui,sans-serif; color:var(--text) }\n  .phone { position:relative; width:390px; height:700px; max-width:100vw; border-radius:44px; overflow:hidden; background:var(--ink) }\n  .feed { position:absolute; inset:0; overflow-y:auto; padding:56px 18px 140px; display:grid; gap:14px }\n  .card { height:150px; border-radius:22px; display:flex; align-items:flex-end; padding:16px; font-weight:600 }\n  .card:nth-child(3n+1) { background:linear-gradient(135deg,#8B6CFF,#2a1d66) }\n  .card:nth-child(3n+2) { background:linear-gradient(135deg,#ff6a3d,#5a1d0b) }\n  .card:nth-child(3n)   { background:linear-gradient(135deg,#C6FF3D,#2d4a00); color:#0A0A0D }\n\n  /* the glass bar */\n  .dock { position:absolute; left:14px; right:14px; bottom:22px; height:76px }\n  .bar { position:absolute; inset:0; border-radius:32px; background:rgba(28,30,40,.45);\n    -webkit-backdrop-filter:blur(16px) saturate(1.9); backdrop-filter:blur(16px) saturate(1.9);\n    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) }\n  .tabs { position:absolute; inset:0; display:flex }\n  .tab { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:3px;\n    border:0; background:none; color:var(--muted); font:600 10.5px system-ui; border-radius:24px; cursor:pointer; touch-action:none }\n  .tab svg { width:25px; height:25px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round }\n  .tab[aria-selected=\"true\"] { color:var(--text) }\n  .tab:focus-visible { outline:2px solid var(--lime); outline-offset:-4px }\n\n  /* the lens: a window onto a scaled copy of the tabs */\n  .lens { position:absolute; left:0; top:0; overflow:hidden; pointer-events:none; will-change:transform,width,height;\n    background:rgba(255,255,255,.07);\n    -webkit-backdrop-filter:blur(3px) saturate(2) brightness(1.18); backdrop-filter:blur(3px) saturate(2) brightness(1.18);\n    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),\n      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) }\n  .lens::after { content:\"\"; position:absolute; left:14%; right:14%; top:5%; height:34%; border-radius:50%;\n    background:linear-gradient(rgba(255,255,255,.5),rgba(255,255,255,0)) }\n  .lensIn { position:absolute; left:0; top:0; transform-origin:0 0;\n    filter:drop-shadow(1.6px 0 0 rgba(255,70,150,.6)) drop-shadow(-1.6px 0 0 rgba(70,210,255,.6)) }\n  .lensIn .tab { color:var(--lime) }\n  .lensIn .tab span { opacity:0 }\n  .lensIn .tab svg { translate:0 8px }\n</style>\n</head>\n<body>\n<div class=\"phone\">\n  <div class=\"feed\" aria-label=\"Feed\">\n    <div class=\"card\">Daily mix</div><div class=\"card\">Late night</div><div class=\"card\">Focus</div>\n    <div class=\"card\">Running</div><div class=\"card\">Vinyl finds</div><div class=\"card\">Live sets</div>\n  </div>\n  <nav class=\"dock\">\n    <div class=\"bar\"></div>\n    <div class=\"tabs\" role=\"tablist\" aria-label=\"Sections\" id=\"tabs\"></div>\n    <div class=\"lens\" id=\"lens\" aria-hidden=\"true\"><div class=\"lensIn\" id=\"lensIn\"></div></div>\n  </nav>\n</div>\n<script>\nconst TABS = [\n  ['Home',    '<path d=\"M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z\"/>'],\n  ['Explore', '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M15.5 8.5l-2 5-5 2 2-5z\"/>'],\n  ['Library', '<path d=\"M4 4v16M9 4v16M14 4l6 16\"/>'],\n  ['Inbox',   '<path d=\"M4 13l2-8h12l2 8v6H4z\"/><path d=\"M4 13h5l1 2h4l1-2h5\"/>'],\n  ['Profile', '<circle cx=\"12\" cy=\"8\" r=\"4\"/><path d=\"M4 21c1.5-4 4.5-6 8-6s6.5 2 8 6\"/>'],\n];\nconst tabHTML = (live) => TABS.map(([label, icon], i) =>\n  `<button class=\"tab\" type=\"button\" ${live ? `role=\"tab\" aria-selected=\"${i === 0}\" tabindex=\"${i ? -1 : 0}\"` : 'tabindex=\"-1\"'}>\n     <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">${icon}</svg><span>${label}</span></button>`).join('');\n\nconst $ = (id) => document.getElementById(id);\nconst dock = document.querySelector('.dock'), tabsEl = $('tabs'), lens = $('lens'), lensIn = $('lensIn');\ntabsEl.innerHTML = tabHTML(true);\nlensIn.innerHTML = `<div class=\"tabs\">${tabHTML(false)}</div>`;   // the copy the lens magnifies\nconst tabs = [...tabsEl.children];\nconst M = 1.42;                                                     // magnification\nconst reduce = matchMedia('(prefers-reduced-motion: reduce)');\n\n// a tiny spring: same feel as Framer Motion's useSpring({ stiffness: 380, damping: 26 })\nconst spring = { x: 0, v: 0, target: 0, k: 380, c: 26 };\nconst centerOf = (i) => tabs[i].offsetLeft + tabs[i].offsetWidth / 2;\nlet active = 0;\n\nfunction select(i, focus) {\n  active = (i + tabs.length) % tabs.length;\n  tabs.forEach((t, j) => { t.setAttribute('aria-selected', j === active); t.tabIndex = j === active ? 0 : -1; });\n  if (focus) tabs[active].focus();\n  spring.target = centerOf(active);\n  if (reduce.matches) { spring.x = spring.target; spring.v = 0; }\n}\n\nfunction paint() {\n  const W = dock.clientWidth, H = dock.clientHeight;\n  lensIn.style.width = W + 'px'; lensIn.style.height = H + 'px';\n  const speed = Math.abs(spring.v);                        // px per second\n  const w = 66 + Math.min(92, speed / 9);                  // faster = longer drop\n  const h = 52 - Math.min(10, speed / 90);                 // ...and a little thinner\n  const x = Math.max(w / 2, Math.min(W - w / 2, spring.x));      // keep the drop inside the bar\n  const y = H / 2, lx = x - w / 2, ly = y - h / 2;\n  lens.style.width = w + 'px'; lens.style.height = h + 'px';\n  lens.style.borderRadius = Math.min(w, h) / 2 + 'px';\n  lens.style.transform = `translate(${lx}px, ${ly}px)`;\n  // keep the point under the lens centre still, and scale everything around it\n  lensIn.style.transform = `translate(${-lx + x - M * x}px, ${-ly + y - M * y}px) scale(${M})`;\n}\n\nlet last = performance.now();\nfunction frame(now) {\n  const dt = Math.min(0.032, (now - last) / 1000); last = now;\n  const a = -spring.k * (spring.x - spring.target) - spring.c * spring.v;   // Hooke + damping, mass 1\n  spring.v += a * dt; spring.x += spring.v * dt;\n  paint();\n  requestAnimationFrame(frame);\n}\n\n// tap, drag across the bar, or use the arrow keys\nconst nearest = (clientX) => {\n  const r = dock.getBoundingClientRect(), px = clientX - r.left;\n  return tabs.reduce((best, t, i) => Math.abs(centerOf(i) - px) < Math.abs(centerOf(best) - px) ? i : best, 0);\n};\ntabsEl.addEventListener('pointerdown', (e) => { tabsEl.setPointerCapture(e.pointerId); select(nearest(e.clientX)); });\ntabsEl.addEventListener('pointermove', (e) => { if (e.buttons) select(nearest(e.clientX)); });\ntabsEl.addEventListener('keydown', (e) => {\n  if (e.key === 'ArrowRight') { e.preventDefault(); select(active + 1, true); }\n  if (e.key === 'ArrowLeft')  { e.preventDefault(); select(active - 1, true); }\n});\naddEventListener('resize', () => { spring.target = spring.x = centerOf(active); });\n\nspring.x = spring.target = centerOf(0);\nrequestAnimationFrame(frame);\n</script>\n</body>\n</html>\n```\n\nThe parts worth reading in the demo:\n\n```\n// a tiny spring: same feel as Framer Motion's useSpring({ stiffness: 380, damping: 26 })\nconst a = -spring.k * (spring.x - spring.target) - spring.c * spring.v;\nspring.v += a * dt; spring.x += spring.v * dt;\n\nconst speed = Math.abs(spring.v);              // px per second\nconst w = 66 + Math.min(92, speed / 9);        // faster = longer drop\nconst h = 52 - Math.min(10, speed / 90);       // ...and a little thinner\nconst x = Math.max(w / 2, Math.min(W - w / 2, spring.x));   // keep the drop inside the bar\nlensIn.style.transform = `translate(${-lx + x - M * x}px, ${-ly + y - M * y}px) scale(${M})`;\n```\n\nThe demo adds three things the shipped component doesn't have:\n\n`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\"`.\n**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](https://qodesign.dev/components/tabbar-liquid?utm_source=devto&utm_medium=tutorial&utm_campaign=tabbar_lens). 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.\n\nIf you build a lens with a real displacement map that works in Safari, I'd like to see it.\n\n*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.*", "url": "https://wpnews.pro/news/a-liquid-glass-tab-bar-the-magnifying-lens-is-a-scaled-copy-of-the-bar", "canonical_source": "https://dev.to/teodoravadani/a-liquid-glass-tab-bar-the-magnifying-lens-is-a-scaled-copy-of-the-bar-2g65", "published_at": "2026-10-20 17:37:17+00:00", "updated_at": "2026-10-20 17:49:09.014690+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Apple", "Safari", "Firefox", "Chromium", "CodePen", "Cursor", "Claude Code"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/a-liquid-glass-tab-bar-the-magnifying-lens-is-a-scaled-copy-of-the-bar", "markdown": "https://wpnews.pro/news/a-liquid-glass-tab-bar-the-magnifying-lens-is-a-scaled-copy-of-the-bar.md", "text": "https://wpnews.pro/news/a-liquid-glass-tab-bar-the-magnifying-lens-is-a-scaled-copy-of-the-bar.txt", "jsonld": "https://wpnews.pro/news/a-liquid-glass-tab-bar-the-magnifying-lens-is-a-scaled-copy-of-the-bar.jsonld"}}