Introduction
This component reads as a floating pill of frosted glass with three tabs and a theme switch, but the 'glass' is one backdrop-filter, the sliding highlight is one absolutely-positioned span, and the light that seems to move across the surface as you hover is a single radial-gradient reading two CSS custom properties. Nothing here uses a physics library or a canvas layer. The depth comes from layered box-shadows and a saturated backdrop blur; the motion comes from writing transform/width onto one element and a couple of CSS variables onto another.
The illusion depends on three layers staying decoupled: the glass shell and the ambient color blobs behind it are pure CSS, computed once and never touched by script. The active pill only knows the bounding box of whichever button is currently selected — it never knows what a tab means. And the cursor glare only knows the mouse position relative to the nav — it never touches layout or the pill at all. Each piece can be deleted independently without breaking the other two.
How to fake real glass with backdrop-filter, layered box-shadow, and a colorful blurred background instead of a flat overlay — how to slide a highlight pill between buttons of different widths using only transform and width — how to build a cursor-tracked glare with a radial-gradient positioned by CSS custom properties and mix-blend-mode: overlay — how to make a tab row keyboard-accessible with roving tabindex and arrow keys instead of relying on click alone — and how to wire a light/dark toggle and a prefers-reduced-motion path without duplicating the component.
Step 1 — Frosted Glass Needs Something Behind It
backdrop-filter: blur() only looks like glass when there is something with color and contrast behind it to blur. A flat gray background makes a blurred panel look like a flat gray panel. So underneath the nav sits a fixed mesh of three large, heavily blurred, softly animated color blobs — an ambient light source the glass can bend. The nav itself is just a flex row with generous border-radius, a translucent background, and a stacked box-shadow: one soft drop shadow for lift, and an inset ring for the glass edge.
.liquid-nav {
background: var(--glass-bg);
backdrop-filter: blur(36px) saturate(180%);
-webkit-backdrop-filter: blur(36px) saturate(180%);
box-shadow:
0 30px 60px -18px var(--glass-shadow),
0 8px 20px -8px var(--glass-shadow),
inset 0 0 0 1px var(--glass-border);
}Every color in that shell is a custom property, not a hardcoded value, and the light/dark theme swap later on is nothing more than reassigning those properties on a data-theme attribute — the shell's own rules never change.
Step 2 — Measuring the Button Instead of Guessing Its Position
The sliding highlight is a single absolutely-positioned span sitting behind the tab buttons. It has no idea how many tabs exist or what they say — on every selection it just reads the clicked button's own offsetLeft and offsetWidth and writes them straight onto itself as a transform and a width. Because buttons can be different widths ('Home' vs 'Messages'), animating width alongside transform is what keeps the pill hugging the label instead of leaving dead space or clipping text.
function movePill(btn, animate = true) {
activePill.style.transition = animate
? 'transform 0.5s cubic-bezier(0.34, 1.4, 0.64, 1), width 0.5s cubic-bezier(0.34, 1.4, 0.64, 1)'
: 'none';
activePill.style.width = btn.offsetWidth + 'px';
activePill.style.transform = `translateX(${btn.offsetLeft}px)`;
}The overshoot-then-settle spring curve on width and transform is what sells the pill as something soft and liquid rather than mechanical — a linear ease on the same code reads as a UI drawer, not glass.
Measuring offsetLeft before the page has fully laid out fonts and icons can return a stale value, so the very first placement happens with transitions turned off via animate = false, then re-enabled for every click afterward. Skipping that step produces a visible pill snapping into place on first paint.
Step 3 — A Light That Follows the Mouse Without Touching Layout
A second absolutely-positioned layer sits above the shell and below the buttons: a radial-gradient centered on two CSS custom properties, --x and --y, blended onto the glass with mix-blend-mode: overlay so it brightens highlights and deepens shadows instead of just painting a flat white circle. The layer starts fully transparent and only fades in on :hover, so it never intrudes when the pointer is elsewhere on the page.
.liquid-glare {
position: absolute;
inset: 0;
opacity: 0;
background: radial-gradient(circle 90px at var(--x, 50%) var(--y, 50%), var(--glare-color), transparent 100%);
mix-blend-mode: overlay;
transition: opacity 0.3s ease;
}
.liquid-nav:hover .liquid-glare { opacity: 1; }nav.addEventListener('mousemove', (e) => {
const rect = nav.getBoundingClientRect();
glare.style.setProperty('--x', (e.clientX - rect.left) + 'px');
glare.style.setProperty('--y', (e.clientY - rect.top) + 'px');
});Because the glare only ever writes two custom properties, it costs nothing to disable — a single prefers-reduced-motion check skips attaching the mousemove listener entirely, and the layer simply stays at opacity 0 forever.
Step 4 — A Tab Row That Works Without a Mouse
Visually this is a row of buttons, but semantically it is a tablist: each button carries role="tab" and aria-selected, and only the currently active tab is reachable by Tab key at all — every other tab sits at tabindex="-1". Arrow Left/Right, Home, and End move focus and selection between tabs directly, which is the standard roving-tabindex pattern and lets someone navigate the whole nav with four keys instead of tabbing through every item individually.
tabsEl.addEventListener('keydown', (e) => {
const current = tabs.findIndex(t => t.getAttribute('aria-selected') === 'true');
let next = null;
if (e.key === 'ArrowRight') next = (current + 1) % tabs.length;
else if (e.key === 'ArrowLeft') next = (current - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = tabs.length - 1;
if (next !== null) {
e.preventDefault();
selectTab(tabs[next], { focus: true });
}
});Both the click handler and the keyboard handler call the same selectTab function, which flips aria-selected, updates tabindex, and moves the pill. That keeps mouse and keyboard interaction guaranteed to stay in sync instead of drifting apart as the component grows.
Step 5 — Swapping Themes Without Swapping Markup
Every color in the component lives under :root and a [data-theme="dark"] override, so the toggle button does nothing more than flip that attribute and set aria-pressed. The sun and moon icons are both always in the DOM, cross-fading and rotating into place with opacity and transform — swapping the actual <svg> on click would jump instead of morph.
themeToggle.addEventListener('click', () => {
const isDark = root.getAttribute('data-theme') === 'dark';
root.setAttribute('data-theme', isDark ? 'light' : 'dark');
themeToggle.setAttribute('aria-pressed', String(!isDark));
setTimeout(() => movePill(currentTab, false), 50);
});That trailing movePill call exists because a theme swap can shift font rendering by a pixel or two, which would leave the pill very slightly misaligned until the next click. Re-measuring immediately after the swap keeps it pixel-accurate without waiting on user interaction.
@media (prefers-reduced-motion: reduce) {
.blob { animation: none; }
.active-pill, .icon-stack svg, button.tab, .icon {
transition-duration: 0.01ms !important;
}
}Tuning Reference
| Property | Example | Effect |
|---|---|---|
| --glass-bg | rgba(255,255,255,0.16) | Base tint of the frosted shell; lower alpha reads as clearer glass, higher alpha as frosted plastic |
| backdrop-filter blur | 36px | How aggressively the blob mesh behind the nav is blurred through the glass |
| pill spring curve | cubic-bezier(0.34, 1.4, 0.64, 1) | Controls the overshoot when the pill slides to a new tab; flatten this for a calmer, less liquid feel |
| glare radius | 90px | Size of the radial-gradient circle following the cursor across the shell |
| blob opacity | 0.3 – 0.5 | How visible the ambient color mesh is behind the glass, tuned per theme so dark mode stays subtle |
| focus ring offset | 2px / 4px | Double-ring focus-visible style that stays legible against the blurred glass background |
Full Source Code
The complete piece is a single self-contained HTML document — the glass shell and blob mesh, the sliding pill, the cursor glare, the roving-tabindex keyboard handling, and the theme toggle with its reduced-motion path, and nothing else. No build step, no external assets, no animation library.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/liquid-glass-nav.html