Animate menu item transitions

by Csaba Hellinger

HTML

<nav class="menu">
  <ul>
    <li><a href="#home">Home</a></li>
    <li><a href="#about">About</a></li>
    <li><a href="#services">Services</a></li>
    <li><a href="#contact">Contact</a></li>
    <li><a href="#faq">FAQ</a></li>
  </ul>
  <div class="tint"></div>
</nav>

CSS

body {
  background: #222;
  margin: 0;
  font-family: 'Helvetica';
  font-weight: 800;
}

/* the menu bar */
.menu {
  position: relative;
  background: linear-gradient(to bottom, #111, #000);
  font-size: 1.5rem;
}

/* the moving “tint” overlay */
.tint {
  position: absolute;
  top: 0;
  bottom: 0;
  background: linear-gradient(to right, #fff, #f00 20%, #f00 80%, #fff);
  view-transition-name: tint;
  mix-blend-mode: multiply;
  _opacity:0.5;
}

::view-transition-group(tint) {
  animation-duration:  800ms;
  animation-timing-function: ease-in-out;
}

/* prevent any Y‑axis drift due to preserving aspect ratio */
::view-transition-old(tint),
::view-transition-new(tint) {
  height: 100%;
}

/* lay out the links horizontally */
.menu ul {
  display: flex;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* style the links */
.menu a {
  display: block;
  padding: 1rem 1.5rem;
  color: #fff;
  text-decoration: none;
  position: relative;
}

JavaScript

// helper that falls back if View Transitions unsupported
function animate(fn) {
  if (document.startViewTransition) {
    document.startViewTransition(fn);
  } else {
    fn();
  }
}

const menu = document.querySelector('.menu');
const tint = menu.querySelector('.tint');
const links = menu.querySelectorAll('a');

function updateTint(el) {
  const linkRect = el.getBoundingClientRect();
  const menuRect = menu.getBoundingClientRect();
  const left = linkRect.left - menuRect.left;

  animate(() => {
    tint.style.left  = `${left}px`;
    tint.style.width = `${linkRect.width}px`;
  });

  // update URL hash without scrolling
  history.replaceState(null, '', el.getAttribute('href'));
}

// wire up clicks
links.forEach(link =>
  link.addEventListener('click', e => {
    e.preventDefault();
    updateTint(link);
  })
);

// on load, position under the current hash (or default to #home)
window.addEventListener('DOMContentLoaded', () => {
  const current = location.hash || '#home';
  const startEl = document.querySelector(`.menu a[href="${current}"]`)
                || links[0];
  updateTint(startEl);
});