Nous allons voir aujourd'hui comment créer un effet d'indicateur animé en utilisant du CSS & du JavaScript. L'objectif est de créer une petite barre qui se place sur l'onglet sélectionné avec un effet de déplacement.
00:00 Première approche
14:53 Approche FLIP
Approche simple
La première approche consiste à créer un élément pour représenter cet indicateur.
<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>
Cet indicateur sera ensuite positionné de manière absolu relativement au menu
.menu {
position: relative;
}
.indicator {
position: absolute;
bottom: 0;
left: 0;
width: 100px;
}
Ensuite, il faudra déplacer ce curseur en fonction de l'élément sélectionné.
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)
})
L'inconvénient de cette approche est le responsive ou le changement de forme du menu. En effet, si le menu change d'apparence il faudra replacer l'indicateur convenablement (en écoutant l'évènement resize
par exemple).
L'approche FLIP
Cette seconde approche est plus complexe mais elle est aussi plus flexible. Elle consiste à placer un indicateur pour chaque élément du menu.
<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>
On le stylise ensuite simplement en 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;
}
Ensuite en JavaScript, lors du passage d'un élément à l'autre, on va comparer la position de l'indicateur de l'ancien élément actif à la position du nouvel élément à activer. À partir de cette information on peut déduire la transformation à applique à l'indicateur actif pour le positionner à la place de l'ancien indicateur.
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
}
// ...
}
Une fois que l'on obtient cette transformation il nous suffit d'animer notre indicateur pour le faire aller de la position précédente à la position courante.
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)'
})
L'avantage de cette approche est que l'animation est automatiquement calculée en fonction de la position des éléments et peut donc s'adapter à toutes les situations (en échange d'une complexité un peu plus forte en terme de logique).