JavaScript Video Tutorial: Lab: Animated Menu Indicator

Today we are going to see how to create an animated flag effect using CSS & JavaScript. The objective is to create a small bar which is placed on the selected tab with a displacement effect.

00:00 First approach
14:53 FLIP approach

Straightforward approach

The first approach is to create an element to represent this indicator.

<nav class="menu">
  <a href="#">Accueil</a>
  <a href="#">Organisation</a>
  <a href="#" aria-selected="true">Prix</a>
  <a href="#">Contact</a>
  <span class="indicator"></span>
</nav>

This indicator will then be positioned absolutely relative to the menu

.menu {
    position: relative;
}
.indicator {
  position: absolute;
  bottom: 0;
  left: 0;
  width: 100px;
}

Then, it will be necessary to move this cursor according to the selected element.

const menu = document.querySelector('.menu')
const menuItems = Array.from(menu.querySelectorAll('a'))
let activeItem = menu.querySelector('[aria-selected]')
const activeIndicator = menu.querySelector('.indicator')

/**
 * Récupère la transformation à appliquer pour déplacer le cursuer sur l'élément 
 * 
 * @param {HTMLElement} element
 * @return {string}
 */
function getTransform (element) {
  const transform = {
    x: element.offsetLeft,
    scaleX: element.offsetWidth / 100,
  }
  return `translateX(${transform.x}px) scaleX(${transform.scaleX}) `
}

function onItemClick (e) {
  if (e.currentTarget === activeItem) {
    return
  }

  activeItem?.removeAttribute('aria-selected')
  e.currentTarget.setAttribute('aria-selected', 'true')

  if (activeItem) {
    activeIndicator.animate([
      {transform: getTransform(e.currentTarget)},
    ], {
      fill: 'both',
      duration: 600,
      easing: 'cubic-bezier(.48,1.55,.28,1)'
    })
  }

  activeItem = e.currentTarget
}

menuItems.forEach(item => {
  item.addEventListener('mouseover', onItemClick)
})

The downside of this approach is the responsive or reshaping of the menu. Indeed, if the menu changes appearance, you will have to replace the indicator appropriately (by listening to the event resize for example).

The FLIP approach

This second approach is more complex but it is also more flexible. It consists of placing an indicator for each menu item.

<nav class="menu">
  <a href="#">Accueil <span class="indicator"></span></a>
  <a href="#">Organisation <span class="indicator"></span></a>
  <a href="#" aria-selected="true">Prix <span class="indicator"></span></a>
  <a href="#">Contact <span class="indicator"></span></a>
</nav>

We then simply style it in CSS.

.menu a {
  position: relative;
}
.indicator {
  position: absolute;
  bottom: 0;
  right: 0;
  left: 0;
  height: 4px;
  border-radius: 4px;
  background: #5396EB;
  opacity: 0;
  transform-origin: 0 0;
}
.menu a[aria-selected] .indicator{
  opacity: 1;
}

Then in JavaScript, when moving from one element to another, we will compare the position of the indicator of the old active element to the position of the new element to activate. From this information we can deduce the transformation to be applied to the active indicator to position it in place of the old indicator.

function onItemClick (e) {
  if (e.currentTarget === activeItem) {
    return
  }

  // On active le bon élément
  activeItem?.removeAttribute('aria-selected')
  e.currentTarget.setAttribute('aria-selected', 'true')

  const prevIndicatorRect = activeItem.querySelector('.indicator').getBoundingClientRect()
  // On récupère la position du nouveau curseur
  const currentIndicator = e.currentTarget.querySelector('.indicator');
  const currentIndicatorRect = currentIndicator.getBoundingClientRect()
  // On fait la différence
  const transform = {
    x: prevIndicatorRect.x - currentIndicatorRect.x,
    y: prevIndicatorRect.y - currentIndicatorRect.y,
    scaleX: prevIndicatorRect.width / currentIndicatorRect.width,
    scaleY: prevIndicatorRect.height / currentIndicatorRect.height
  }
  // ...
}

Once we obtain this transformation, we just need to animate our indicator to make it go from the previous position to the current position.

currentIndicator.animate([
  {transform: `translate3d(${transform.x}px, ${transform.y}px, 0) scale(${transform.scaleX}, ${transform.scaleY}) `},
  {transform: 'translate3d(0, 0, 0) scale(1, 1)'}
], {
  fill: 'none',
  duration: 600,
  easing: 'cubic-bezier(.48,1.55,.28,1)'
})

The advantage of this approach is that the animation is automatically calculated according to the position of the elements and can therefore adapt to all situations (in exchange for a slightly greater complexity in terms of logic).