A Modern Take on the Moving-Highlight Nav Bar

The “moving-highlight” navigation pattern — where the border around the active menu item glides to the newly selected item — used to be a staple of jQuery tutorials. That era is over. With vanilla JavaScript and modern CSS, the same effect requires far less code and integrates more cleanly with today’s web platform features. Two approaches stand out: one based on getBoundingClientRect and CSS transitions, and another that leverages the View Transition API for a more declarative solution.

Starting With Basic Markup and Styles

For both methods, assume a single-page layout where content changes without a full page reload. The baseline navigation uses standard HTML, an extra div with the ID #highlight, and a class of .active on the first nav link.

See the Pen [Moving Highlight Navbar Starting Markup [forked]](https://codepen.io/smashingmag/pen/EajQyBW) by Blake Lundquist.

See the Pen Moving Highlight Navbar Starting Markup [forked] by Blake Lundquist.

The first technique positions #highlight absolutely so it sits around the active element. To animate it, the element is initially hidden off-screen with left: -200px and styled with a transition property so any change in its position or size happens gradually over time.

#highlight {
  z-index: 0;
  position: absolute;
  height: 100%;
  width: 100px;
  left: -200px;
  border: 2px solid green;
  box-sizing: border-box;
  transition: all 0.2s ease;
}

Handling Clicks With a Single Event Listener

Rather than attaching a listener to each link, attach one click handler to the nav element and filter events to only those originating from an anchor without the .active class. A console.log check confirms the handler fires only when a new item is chosen.

const navbar = document.querySelector('nav');

navbar.addEventListener('click', function (event) {
  // return if the clicked element doesn't have the correct selector
  if (!event.target.matches('nav a:not(active)')) {
    return;
  }
  
  console.log('click');
});

Next, move the .active class: remove it from the currently active link and add it to the clicked one. Then, to reposition the highlight, write a function that reads the active item’s geometry via getBoundingClientRect — its width and its offset from the parent’s left edge — and applies those values as styles to #highlight. Because of the transition styles, the update animates smoothly.

// handler for moving the highlight
const moveHighlight = () => {
  const activeNavItem = document.querySelector('a.active');
  const highlighterElement = document.querySelector('#highlight');
  
  const width = activeNavItem.offsetWidth;

  const itemPos = activeNavItem.getBoundingClientRect();
  const navbarPos = navbar.getBoundingClientRect()
  const relativePosX = itemPos.left - navbarPos.left;

  const styles = {
    left: `${relativePosX}px`,
    width: `${width}px`,
  };

  Object.assign(highlighterElement.style, styles);
}

Wire the function into the click handler and call it once on page load so the border initially sits behind the first active item.

// handler for moving the highlight
const moveHighlight = () => {
 // ...
}

// display the highlight when the page loads
moveHighlight();

At this point, clicking any nav link slides the border across the bar. The code is minimal, and the same pattern can be extended to respond to other interactions, such as mouseover, with very little extra work.

Refactoring With the View Transition API

The View Transition API, which powers the animated transitions in tools like Astro, can also handle single-page view updates. Under the hood, it captures “before” and “after” snapshots of the UI and animates between them. For the moving-highlight nav, this means removing the dedicated #highlight div entirely and styling the active link itself with a pseudo-element.

Start by deleting the #highlight element and its CSS, replacing it with styles for the nav a::after pseudo-selector. Then, give the .active class a view-transition-name property (e.g., highlight) so the browser knows to animate that specific element when the transition runs.

nav a.active::after {
  border: 2px solid green;
  view-transition-name: highlight;
}

The JavaScript becomes a simple call to document.startViewTransition, passing in a callback that updates which navigation item carries the .active class. The browser does all the positioning and animation work.

const navbar = document.querySelector('nav');

// Change the active nav item on click
navbar.addEventListener('click', async  function (event) {

  if (!event.target.matches('nav a:not(.active)')) {
    return;
  }
  
  document.startViewTransition(() => {
    document.querySelector('nav a.active').classList.remove('active');

    event.target.classList.add('active');
  });
});

Fixing Aspect Ratio Issues

You’ll likely notice some visual glitches on first run — the border’s height can distort during the animation. This happens because the before-and-after snapshots differ in aspect ratio. The fix is to explicitly set a height on the ::view-transition-old and ::view-transition-new pseudo-elements, which represent the static before and after snapshots, ensuring a consistent border height throughout the transition.

::view-transition-old(highlight) {
  height: 100%;
}

::view-transition-new(highlight) {
  height: 100%;
}

For a cleaner final version, move the callback into its own function and add a feature check so the click handler falls back to simply toggling the .active class when the View Transition API isn’t available.

const navbar = document.querySelector('nav');

// change the item that has the .active class applied
const setActiveElement = (elem) => {
  document.querySelector('nav a.active').classList.remove('active');
  elem.classList.add('active');
}

// Start view transition and pass in a callback on click
navbar.addEventListener('click', async  function (event) {
  if (!event.target.matches('nav a:not(.active)')) {
    return;
  }

  // Fallback for browsers that don't support View Transitions:
  if (!document.startViewTransition) {
    setActiveElement(event.target);
    return;
  }
  
  document.startViewTransition(() => setActiveElement(event.target));
});

This approach cuts the moving-highlight nav down to a few lines of vanilla JavaScript and leaves the heavy lifting of animation to the browser. It’s a clear demonstration of how modern web features are replacing the need for external animation libraries.