Tailwind v4MIT licensed

Tailwind Cursor-Lens Glass Nav (GSAP-ready)

A bright refraction lens that follows the cursor across the frosted bar — the effect people install GSAP for. The port keeps the lens itself entirely in utilities and leaves exactly two numbers to JavaScript: an eased --nb15-mx and --nb15-my. Swapping the hand-rolled lerp for gsap.quickTo() is then a three-line change that touches no markup.

Live Demo
Plain CSSTailwind v4
Try it

Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time

Untitled UI React · Affiliate link

Get Untitled UI Pro

The code

<section class="nbtw-15 grid place-items-center w-full min-h-svh p-5 font-nb15-sans bg-[conic-gradient(from_90deg_at_50%_50%,#ff8a00,#e52e71,#7f00ff,#ff8a00)] bg-[length:200%_200%] animate-nb15-bg motion-reduce:animate-none" aria-label="Cursor lens interactive glass nav demo">
  <nav class="relative overflow-hidden flex flex-wrap items-center gap-x-5.5 gap-y-3 px-5 py-3.5 rounded-[20px] isolate text-white bg-[color-mix(in_oklab,white_12%,transparent)] border border-white/[0.34] shadow-[inset_0_1px_0_rgba(255,255,255,.5),0_22px_46px_-24px_rgba(0,0,0,.6)] backdrop-blur-[20px] backdrop-saturate-[1.8]" id="nbtw-15-bar" aria-label="Primary">
    <span class="absolute inset-0 z-0 rounded-[inherit] pointer-events-none bg-[radial-gradient(140px_140px_at_var(--nb15-mx,50%)_var(--nb15-my,50%),rgba(255,255,255,.55),rgba(255,255,255,.12)_40%,transparent_65%)] [opacity:var(--nb15-on,0)] transition-opacity duration-300 motion-reduce:transition-none" aria-hidden="true"></span>
    <span class="relative z-[1] text-base font-extrabold">◇ Refract</span>
    <div class="relative z-[1] flex flex-wrap gap-1.5">
      <a class="flex items-center min-h-10 px-4 rounded-xl no-underline text-white opacity-90 text-[14.5px] font-semibold transition-[background-color,opacity] duration-200 hover:opacity-100 hover:bg-white/[0.18] focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2 aria-[current=page]:opacity-100 aria-[current=page]:bg-white/[0.18]" href="#nbtw-15" aria-current="page">Work</a>
      <a class="flex items-center min-h-10 px-4 rounded-xl no-underline text-white opacity-90 text-[14.5px] font-semibold transition-[background-color,opacity] duration-200 hover:opacity-100 hover:bg-white/[0.18] focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-15">Studio</a>
      <a class="flex items-center min-h-10 px-4 rounded-xl no-underline text-white opacity-90 text-[14.5px] font-semibold transition-[background-color,opacity] duration-200 hover:opacity-100 hover:bg-white/[0.18] focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-15">Lab</a>
      <a class="flex items-center min-h-10 px-4 rounded-xl no-underline text-white opacity-90 text-[14.5px] font-semibold transition-[background-color,opacity] duration-200 hover:opacity-100 hover:bg-white/[0.18] focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-15">Contact</a>
    </div>
  </nav>
</section>
@import "tailwindcss";

@theme {
  --font-nb15-sans: system-ui, 'Segoe UI', sans-serif;
  --animate-nb15-bg: nb15-bg 22s ease-in-out infinite;
}

@keyframes nb15-bg {
  50% {
    background-position: 100% 100%;
  }
}
(() => {
  const bar = document.querySelector('#nbtw-15-bar');
  if (!bar) return;
  let tx = 50, ty = 50, cx = 50, cy = 50, raf = null;
  // GSAP upgrade: const qx = gsap.quickTo(bar, '--nb15-mx', { duration: .4, unit: '%' });
  const loop = () => {
    cx += (tx - cx) * 0.18;
    cy += (ty - cy) * 0.18;
    bar.style.setProperty('--nb15-mx', cx.toFixed(1) + '%');
    bar.style.setProperty('--nb15-my', cy.toFixed(1) + '%');
    raf = (Math.abs(tx - cx) > 0.1 || Math.abs(ty - cy) > 0.1) ? requestAnimationFrame(loop) : null;
  };
  bar.addEventListener('pointermove', (e) => {
    const r = bar.getBoundingClientRect();
    tx = ((e.clientX - r.left) / r.width) * 100;
    ty = ((e.clientY - r.top) / r.height) * 100;
    bar.style.setProperty('--nb15-on', '1');
    if (!raf) raf = requestAnimationFrame(loop);
  });
  bar.addEventListener('pointerleave', () => bar.style.setProperty('--nb15-on', '0'));
})();

Setup — do this once per project

Skip this if your project already runs Tailwind v4.

1Load Tailwind
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>

Compiles in the browser — perfect for a CodePen or a quick test, not for production.

2Add the stylesheetRequiredthe @theme block is what makes the custom utilities exist
@import "tailwindcss";

@theme {
  --font-nb15-sans: system-ui, 'Segoe UI', sans-serif;
  --animate-nb15-bg: nb15-bg 22s ease-in-out infinite;
}

@keyframes nb15-bg {
  50% {
    background-position: 100% 100%;
  }
}

What changed in the port

Which parts became utilities, which stayed CSS, and why.

  • The lens is entirely utilities except for two live coordinates, which is the cleanest possible split: appearance in the markup, motion in the handler.
  • Both the position and the presence are custom properties, so one handler owns the whole effect and there is no class to toggle.
  • Easing the write is what the effect is actually made of. A raw pointer write snaps; a single-pole lerp gives it the weight people install a tween library for.
  • The rAF loop exits once the lens arrives, so an idle bar costs nothing — the difference between shipping this and regretting it.
  • [opacity:var(--nb15-on,0)] carries its own fallback, otherwise the lens is fully visible before the first pointer event.
  • The GSAP path is a comment in the script for a reason: gsap.quickTo() writes the same two properties, so upgrading changes no markup at all.
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Cursor-Lens Glass Nav (GSAP-ready)
Source: https://codefronts.com/navigation/tailwind-liquid-glass-navbars/tailwind-cursor-lens-glass-nav-gsap-ready/

A bright refraction lens that follows the cursor across the frosted bar &mdash; the effect people install GSAP for. The port keeps the lens itself entirely in utilities and leaves exactly two numbers to JavaScript: an eased --nb15-mx and --nb15-my. Swapping the hand-rolled lerp for gsap.quickTo() is then a three-line change that touches no markup.
## HTML
```html
<section class="nbtw-15 grid place-items-center w-full min-h-svh p-5 font-nb15-sans bg-[conic-gradient(from_90deg_at_50%_50%,#ff8a00,#e52e71,#7f00ff,#ff8a00)] bg-[length:200%_200%] animate-nb15-bg motion-reduce:animate-none" aria-label="Cursor lens interactive glass nav demo">
  <nav class="relative overflow-hidden flex flex-wrap items-center gap-x-5.5 gap-y-3 px-5 py-3.5 rounded-[20px] isolate text-white bg-[color-mix(in_oklab,white_12%,transparent)] border border-white/[0.34] shadow-[inset_0_1px_0_rgba(255,255,255,.5),0_22px_46px_-24px_rgba(0,0,0,.6)] backdrop-blur-[20px] backdrop-saturate-[1.8]" id="nbtw-15-bar" aria-label="Primary">
    <span class="absolute inset-0 z-0 rounded-[inherit] pointer-events-none bg-[radial-gradient(140px_140px_at_var(--nb15-mx,50%)_var(--nb15-my,50%),rgba(255,255,255,.55),rgba(255,255,255,.12)_40%,transparent_65%)] [opacity:var(--nb15-on,0)] transition-opacity duration-300 motion-reduce:transition-none" aria-hidden="true"></span>
    <span class="relative z-[1] text-base font-extrabold">◇ Refract</span>
    <div class="relative z-[1] flex flex-wrap gap-1.5">
      <a class="flex items-center min-h-10 px-4 rounded-xl no-underline text-white opacity-90 text-[14.5px] font-semibold transition-[background-color,opacity] duration-200 hover:opacity-100 hover:bg-white/[0.18] focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2 aria-[current=page]:opacity-100 aria-[current=page]:bg-white/[0.18]" href="#nbtw-15" aria-current="page">Work</a>
      <a class="flex items-center min-h-10 px-4 rounded-xl no-underline text-white opacity-90 text-[14.5px] font-semibold transition-[background-color,opacity] duration-200 hover:opacity-100 hover:bg-white/[0.18] focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-15">Studio</a>
      <a class="flex items-center min-h-10 px-4 rounded-xl no-underline text-white opacity-90 text-[14.5px] font-semibold transition-[background-color,opacity] duration-200 hover:opacity-100 hover:bg-white/[0.18] focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-15">Lab</a>
      <a class="flex items-center min-h-10 px-4 rounded-xl no-underline text-white opacity-90 text-[14.5px] font-semibold transition-[background-color,opacity] duration-200 hover:opacity-100 hover:bg-white/[0.18] focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-15">Contact</a>
    </div>
  </nav>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --font-nb15-sans: system-ui, 'Segoe UI', sans-serif;
  --animate-nb15-bg: nb15-bg 22s ease-in-out infinite;
}

@keyframes nb15-bg {
  50% {
    background-position: 100% 100%;
  }
}
```

## JavaScript
```js
(() => {
  const bar = document.querySelector('#nbtw-15-bar');
  if (!bar) return;
  let tx = 50, ty = 50, cx = 50, cy = 50, raf = null;
  // GSAP upgrade: const qx = gsap.quickTo(bar, '--nb15-mx', { duration: .4, unit: '%' });
  const loop = () => {
    cx += (tx - cx) * 0.18;
    cy += (ty - cy) * 0.18;
    bar.style.setProperty('--nb15-mx', cx.toFixed(1) + '%');
    bar.style.setProperty('--nb15-my', cy.toFixed(1) + '%');
    raf = (Math.abs(tx - cx) > 0.1 || Math.abs(ty - cy) > 0.1) ? requestAnimationFrame(loop) : null;
  };
  bar.addEventListener('pointermove', (e) => {
    const r = bar.getBoundingClientRect();
    tx = ((e.clientX - r.left) / r.width) * 100;
    ty = ((e.clientY - r.top) / r.height) * 100;
    bar.style.setProperty('--nb15-on', '1');
    if (!raf) raf = requestAnimationFrame(loop);
  });
  bar.addEventListener('pointerleave', () => bar.style.setProperty('--nb15-on', '0'));
})();
```

How this works

The lens is a utility; its position is data. Everything visual is declared once:

class="absolute inset-0 z-0 rounded-[inherit] pointer-events-none
       bg-[radial-gradient(140px_140px_at_var(--nb15-mx,50%)_var(--nb15-my,50%),rgba(255,255,255,.55),rgba(255,255,255,.12)_40%,transparent_65%)]
       [opacity:var(--nb15-on,0)] transition-opacity duration-300"

Easing the write is the whole feel. Writing the raw pointer position each frame produces a lens that snaps to the cursor and reads as cheap. A single-pole lerp — cx += (tx - cx) * 0.18 — gives it the weight people associate with a tweened library, at a fraction of the cost:

// GSAP: const qx = gsap.quickTo(bar, '--nb15-mx', { duration: 0.4, unit: '%' });
cx += (tx - cx) * 0.18;
bar.style.setProperty('--nb15-mx', cx.toFixed(1) + '%');

The loop stops when it arrives. The rAF chain exits once the lens is within a tenth of a percent of the target, so an idle bar costs nothing — the discipline that separates a lens you can ship from one that burns a core.

Fading is --nb15-on, not a class. Pointer enter sets it to 1, leave sets it to 0, and the opacity transition does the rest. Keeping both the position and the presence as properties is what lets one handler own the whole effect.

Make it yours

  • Replace the lerp with gsap.quickTo() on both properties — same variables, same markup, better easing for free.
  • Resize the lens with the two lengths in the gradient, or soften it by moving the second stop outward.
  • Tint the lens by swapping the white stops for a color-mix() with your accent; keep the outer stop transparent.
  • Add literal warp by pairing the lens with the SVG displacement filter from demo 03 on a sibling layer.
  • Drive it from focus as well as pointer by writing the coordinates of the focused link's centre on focusin.

Gotchas — read before shipping

  • Ease the property writes. An unsmoothed lens snaps to the cursor and immediately reads as cheap — this is the difference the GSAP crowd is paying for.
  • Exit the rAF loop when the lens reaches its target. A permanently running loop on a resting navbar is a real battery cost.
  • Keep the lens aria-hidden and pointer-events-none; a full-bleed decorative layer over a nav is otherwise a click sink.
  • [opacity:var(--nb15-on,0)] needs the fallback in the var(), or the lens is fully opaque before the first pointer event.
  • Only write when the pointer has actually moved. Firing on every event regardless is what makes this pattern show up in INP traces.
  • Touch devices have no hover. The lens simply never appears there, which is fine — do not put anything essential inside it.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

Tailwind v4 sets the floor. The lens is enhancement: with no JS the bar is a static frosted nav, and GSAP is entirely optional — the vanilla lerp shown here is the whole dependency.

Techniques used in this demo

Plain CSSno Tailwind surface

Search CodeFronts

Loading…