Tailwind v4MIT licensed

Tailwind Spring Glide Menu (Framer Motion Port)

The shared-element indicator: one frosted pill glides between links on a spring, the layoutId feel without the library. The port draws a clean line — the pill's look and its transition are utilities, its two measured numbers are inline styles written by the handler, and the notes are explicit that measurement is the one job utilities cannot do.

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-10 grid place-items-center w-full min-h-svh p-5 font-nb10-sans bg-[conic-gradient(from_30deg_at_40%_40%,#00dbde,#fc00ff,#00dbde)] bg-[length:200%_200%] animate-nb10-bg motion-reduce:animate-none" aria-label="Spring glide menu demo">
  <nav class="relative flex flex-wrap gap-1 p-2 rounded-full isolate bg-[color-mix(in_oklab,white_14%,transparent)] border border-white/40 shadow-[inset_0_1px_0_rgba(255,255,255,.55),0_20px_44px_-22px_rgba(0,0,0,.55)] backdrop-blur-[22px] backdrop-saturate-[1.8]" id="nbtw-10-bar" aria-label="Primary">
    <span class="absolute inset-y-2 left-2 z-0 w-24 rounded-full bg-white/[0.24] border border-white/50 shadow-[inset_0_1px_0_rgba(255,255,255,.7)] transition-[left,width] duration-500 ease-nb10 motion-reduce:transition-none" id="nbtw-10-pill" aria-hidden="true"></span>
    <a class="relative z-[1] flex items-center min-h-10 px-4.5 rounded-full no-underline text-white text-[14.5px] font-semibold transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-10" aria-current="page">Overview</a>
    <a class="relative z-[1] flex items-center min-h-10 px-4.5 rounded-full no-underline text-white text-[14.5px] font-semibold transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-10">Analytics</a>
    <a class="relative z-[1] flex items-center min-h-10 px-4.5 rounded-full no-underline text-white text-[14.5px] font-semibold transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-10">Reports</a>
    <a class="relative z-[1] flex items-center min-h-10 px-4.5 rounded-full no-underline text-white text-[14.5px] font-semibold transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-10">Settings</a>
  </nav>
</section>
@import "tailwindcss";

@theme {
  --font-nb10-sans: system-ui, 'Segoe UI', sans-serif;
  --ease-nb10: cubic-bezier(0.34, 1.56, 0.5, 1);
  --animate-nb10-bg: nb10-bg 18s ease-in-out infinite;
}

@keyframes nb10-bg {
  50% {
    background-position: 100% 100%;
  }
}
(() => {
  const bar = document.querySelector('#nbtw-10-bar');
  const pill = document.querySelector('#nbtw-10-pill');
  if (!bar || !pill) return;
  const links = [...bar.querySelectorAll('a')];
  let active = links.find((l) => l.hasAttribute('aria-current')) || links[0];
  const move = (el) => {
    const b = bar.getBoundingClientRect(), r = el.getBoundingClientRect();
    pill.style.left = (r.left - b.left) + 'px';
    pill.style.width = r.width + 'px';
  };
  links.forEach((l) => {
    l.addEventListener('pointerenter', () => move(l));
    l.addEventListener('focus', () => move(l));
    l.addEventListener('click', (e) => {
      e.preventDefault();
      links.forEach((x) => x.removeAttribute('aria-current'));
      l.setAttribute('aria-current', 'page');
      active = l;
    });
  });
  bar.addEventListener('pointerleave', () => move(active));
  requestAnimationFrame(() => move(active));
  addEventListener('resize', () => move(active));
})();

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-nb10-sans: system-ui, 'Segoe UI', sans-serif;
  --ease-nb10: cubic-bezier(0.34, 1.56, 0.5, 1);
  --animate-nb10-bg: nb10-bg 18s ease-in-out infinite;
}

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

What changed in the port

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

  • The port draws a clean line: the pill's appearance and its transition are utilities, and only the two measured numbers are inline styles. Measurement is the job utilities genuinely cannot do.
  • A runtime-computed class name would be invisible to the compiler, which is why pill.style.left is correct here rather than a lapse.
  • The spring is a --ease-* theme entry, so ease-nb10 names the curve once instead of four magic numbers repeating across elements.
  • focus is listened to alongside pointerenter, so tabbing glides the pill too — the half of this pattern most implementations forget.
  • The indicator is aria-hidden and the anchors keep aria-current, so there is exactly one source of truth for which link is active.
  • Swapping in Framer Motion means deleting the handler and wrapping the indicator in motion.div layoutId; every utility in the class list stays.
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 Spring Glide Menu (Framer Motion Port)
Source: https://codefronts.com/navigation/tailwind-liquid-glass-navbars/tailwind-spring-glide-menu-framer-motion-port/

The shared-element indicator: one frosted pill glides between links on a spring, the layoutId feel without the library. The port draws a clean line &mdash; the pill's look and its transition are utilities, its two measured numbers are inline styles written by the handler, and the notes are explicit that measurement is the one job utilities cannot do.
## HTML
```html
<section class="nbtw-10 grid place-items-center w-full min-h-svh p-5 font-nb10-sans bg-[conic-gradient(from_30deg_at_40%_40%,#00dbde,#fc00ff,#00dbde)] bg-[length:200%_200%] animate-nb10-bg motion-reduce:animate-none" aria-label="Spring glide menu demo">
  <nav class="relative flex flex-wrap gap-1 p-2 rounded-full isolate bg-[color-mix(in_oklab,white_14%,transparent)] border border-white/40 shadow-[inset_0_1px_0_rgba(255,255,255,.55),0_20px_44px_-22px_rgba(0,0,0,.55)] backdrop-blur-[22px] backdrop-saturate-[1.8]" id="nbtw-10-bar" aria-label="Primary">
    <span class="absolute inset-y-2 left-2 z-0 w-24 rounded-full bg-white/[0.24] border border-white/50 shadow-[inset_0_1px_0_rgba(255,255,255,.7)] transition-[left,width] duration-500 ease-nb10 motion-reduce:transition-none" id="nbtw-10-pill" aria-hidden="true"></span>
    <a class="relative z-[1] flex items-center min-h-10 px-4.5 rounded-full no-underline text-white text-[14.5px] font-semibold transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-10" aria-current="page">Overview</a>
    <a class="relative z-[1] flex items-center min-h-10 px-4.5 rounded-full no-underline text-white text-[14.5px] font-semibold transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-10">Analytics</a>
    <a class="relative z-[1] flex items-center min-h-10 px-4.5 rounded-full no-underline text-white text-[14.5px] font-semibold transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-10">Reports</a>
    <a class="relative z-[1] flex items-center min-h-10 px-4.5 rounded-full no-underline text-white text-[14.5px] font-semibold transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#nbtw-10">Settings</a>
  </nav>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --font-nb10-sans: system-ui, 'Segoe UI', sans-serif;
  --ease-nb10: cubic-bezier(0.34, 1.56, 0.5, 1);
  --animate-nb10-bg: nb10-bg 18s ease-in-out infinite;
}

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

## JavaScript
```js
(() => {
  const bar = document.querySelector('#nbtw-10-bar');
  const pill = document.querySelector('#nbtw-10-pill');
  if (!bar || !pill) return;
  const links = [...bar.querySelectorAll('a')];
  let active = links.find((l) => l.hasAttribute('aria-current')) || links[0];
  const move = (el) => {
    const b = bar.getBoundingClientRect(), r = el.getBoundingClientRect();
    pill.style.left = (r.left - b.left) + 'px';
    pill.style.width = r.width + 'px';
  };
  links.forEach((l) => {
    l.addEventListener('pointerenter', () => move(l));
    l.addEventListener('focus', () => move(l));
    l.addEventListener('click', (e) => {
      e.preventDefault();
      links.forEach((x) => x.removeAttribute('aria-current'));
      l.setAttribute('aria-current', 'page');
      active = l;
    });
  });
  bar.addEventListener('pointerleave', () => move(active));
  requestAnimationFrame(() => move(active));
  addEventListener('resize', () => move(active));
})();
```

How this works

Utilities style the pill; JavaScript measures it. That split is the whole port. Everything about how the indicator looks and how it moves is in the class list:

class="absolute inset-y-2 left-2 w-24 rounded-full z-0
       bg-white/[0.24] border border-white/50
       shadow-[inset_0_1px_0_rgba(255,255,255,.7)]
       transition-[left,width] duration-500 ease-nb10"

and the handler contributes only two numbers that no CSS can know:

const b = bar.getBoundingClientRect(), r = el.getBoundingClientRect();
pill.style.left = (r.left - b.left) + 'px';
pill.style.width = r.width + 'px';

The spring is a token. --ease-nb10: cubic-bezier(0.34, 1.56, 0.5, 1) mints ease-nb10, so the curve is named once and every element that should feel the same overshoot asks for it by name rather than repeating four magic numbers.

Hover previews, active commits. Pointer and focus move the pill to the hovered link; leaving the bar returns it to the current one. Clicking rewrites aria-current, which is also what the link styling reads — there is no separate active class to keep in sync.

In Framer Motion this is layoutId. Wrap the indicator in <motion.div layoutId="pill" transition={{type:'spring'}}/>, delete the handler, and keep every utility in the class list exactly as it is.

Make it yours

  • Tune the spring by editing --ease-nb10; more overshoot in the third control point reads bouncier, less reads like glass.
  • Make it hover-only or active-only by removing one listener set; the markup does not change.
  • Switch to Framer Motion with layoutId and a spring transition, dropping the two style writes and the resize listener.
  • Animate transform instead of left for a cheaper glide: write translate3d and a scaleX from the measured ratio.
  • Recolour the pill to a brand tint with a @theme colour if the frosted white reads too neutral in your palette.

Gotchas — read before shipping

  • Re-measure on resize and after webfonts load, or the pill drifts out of alignment with no visible cause.
  • Keep the indicator aria-hidden. It is decoration; the anchors carry aria-current, and duplicating state into the pill is how the two get out of sync.
  • left and width are layout properties. They are fine for a small indicator but a transform-based version is the right call inside a scroll container.
  • Do not try to express the measured values as utilities. A runtime-computed class name is invisible to the compiler — inline styles are correct here.
  • Bind pointerleave on the bar rather than each link, or the pill jitters as the pointer crosses gaps between links.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

Tailwind v4 sets the floor. The glide is enhancement: with no JS the pill sits under the first link and the active link is still marked by its own aria-current styling.

Techniques used in this demo

Plain CSSno Tailwind surface

Search CodeFronts

Loading…