The other day, I was working on a project and realized how much a simple thing like a menu’s movement can change the whole feel of a site. So I decided to mimic the animation and simulate smooth, natural motion using physics concepts, making the animation feel lifelike, similar to how objects move and decelerate in nature. Below is a demo of what I am talking about.
Demo
We’ll walk through how the menu’s background handle smoothly follows the active link when hovering, resizing and repositioning in a natural way.
Dependencies
Make sure you have these dependencies installed:
npm install solid-js gsap clsx
Code Overview
We will be working with the below navigation data. You can replace this with your own data.
const items = [ { TEXT: 'Home', HREF: '/' }, { TEXT: 'Blog', HREF: '/blog' }, { TEXT: 'Projects', HREF: '/projects' }, { TEXT: 'About', HREF: '/about' }, ];
Create the component and import dependencies
We will create a new component called NavigationMenu
and import the necessary dependencies.
It will accept props - currentPath
, slug
, containerClass
, and items
.
import { createSignal, onMount } from 'solid-js'; import gsap from 'gsap'; import clsx from 'clsx'; const Props { currentPath: string; slug: string; containerClass?: string; handleClass?: string; items: { TEXT: string; HREF: string }[]; } export default function NavigationMenu(props: Props) { const { currentPath, slug, containerClass, items,handleClass } = props; //... return ( <div class="p-2 inline-block rounded-full"> <div class="relative"> <nav id="nav-menu" class="p-0 m-0 flex cursor-pointer relative z-10 text-xs" onMouseOver={} > {items.map((item) => ( <a class={clsx('px-4 py-2.5 m-0 text-sm duration-500')} href={item.HREF} > {item.TEXT} </a> ))} </nav> <span class={clsx( 'rounded-full absolute left-0 h-full top-0 handle', handleClass )}></span> </div> </div> ); }
This is how it looks:
Store the index of the hovered link
Here, we create a state using createSignal
function which tracks the index of the active link - activeIndex
. When a user hovers over a link, we call activateLink
, which calculates its index and sets activeIndex
.
const [activeIndex, setActiveIndex] = createSignal(0); let original: HTMLAnchorElement | null = null; const activateLink = (element: HTMLAnchorElement) => { if (element.parentElement !== null) { setActiveIndex([...element?.parentElement.children].indexOf(element)); } }; const onMouseOver = (event: MouseEvent) => { const hoveredElement = event.target; if (hoveredElement.tagName === 'A') { activateLink(hoveredElement); } }; return ( ... <nav ... onMouseOver={onMouseOver} > ... </nav> ... );
I am priting the activeIndex
in this demo. This is how it looks:
Activating the background handle
To activate the background handle, we need to update the handle’s position and size based on the active link. We’ll create a function called updateHandlePosition
that takes the active link element as an argument and animates the handle to match the link’s position and size.
const activateLink = (element: HTMLAnchorElement) => { ... updateHandlePosition(element); // update when hover }; const updateHandlePosition = (element: HTMLAnchorElement) => { if (!handle || !element) return; const rect = element.getBoundingClientRect(); const parentRect = element.parentElement?.getBoundingClientRect(); if (!parentRect) return; const offsetX = rect.left - parentRect.left; handle.style.width = `${rect.width}px`; handle.style.transform = `translateX(${offsetX}px) scaleX(1)`; };
This is how it looks:
Animating the Handle
We use GSAP to move and resize the handle. We will retrieve the width and left offset of the element
using getBoundingClientRect()
which provides the link’s dimensions and its position relative to the viewport.
const rect = element.getBoundingClientRect();
Similarly, it retrieves the bounding rectangle of the element
’s parent (nav
). The parent’s position is necessary to calculate the exact offset of the link relative to its container, not the whole page.
const parentRect = element.parentElement?.getBoundingClientRect();
const updateHandlePosition = (element: HTMLAnchorElement) => { if (!handle || !element) return; const rect = element.getBoundingClientRect(); const parentRect = element.parentElement?.getBoundingClientRect(); if (!parentRect) return; const offsetX = rect.left - parentRect.left; gsap.killTweensOf(handle); gsap.fromTo( handle, { scaleX: 1.5 }, { width: rect.width, x: offsetX, scaleX: 1, opacity: 1, duration: 0.3, ease: 'power1.out' } ); };
This is how it looks:
GSAP’s
killTweensOf(handle)
is called to stop any ongoing animations that might still be affecting the handle. This ensures that no overlapping or unfinished animations disrupt the new one.
Animate the Handle with GSAP:
The fromTo()
method accepts three arguments and is used to animate the handle:
- Parameter 1: The element you want to animate.
- Parameter 2: An object containing the initial properties of the element.
- Parameter 3: An object containing the final properties of the element.
Initialize the Active Link
In this demo, lets assume that the user is on the /blog
page. Just like in React we have useEffect
, in Solid we have onMount
which is called when the component is mounted. We will initialize the active link inside onMount
method.
const activateLink = (element: HTMLAnchorElement) => { ... updateHandlePosition(element); }; onMount(() => { const activeLink = LINKS.find( ({ HREF }) => HREF === slug || currentPath === HREF )?.HREF; const activeElement = document.querySelector( `#nav-menu a[href='${activeLink}']` ); original = activeElement as HTMLAnchorElement; activateLink(original); });
You will notice that we save the active link in the original
variable. This is because we will need to reset the active link when the mouse leaves the link.
And finally, when we move the mouse away, we will reset the active link based on the current URL - /blog
.
const onMouseOver = (event: MouseEvent) => { const hoveredElement = event.target as HTMLAnchorElement; if (hoveredElement.tagName === 'A') { activateLink(hoveredElement); // Reset the active link when mouse leaves hoveredElement.addEventListener('mouseleave', () => { activateLink(original); }); } };
This is how it looks:
When the user hovers over a link, it activates the hovered link by calling activateLink
. It also adds an event listener to detect when the mouse leaves the link, at which point activateLink
is called to reset the active link with the original link.
Conclusion
Small details often have a significant impact, and animations are a perfect example of this. While animations themselves are not a recent innovation, they used to come with their own set of challenges, particularly around performance. In the past, incorporating animations into websites or applications often resulted in slow, laggy experiences, especially on less powerful devices or older browsers. Developers had to make trade-offs, either sacrificing smoothness or limiting the use of animations altogether.
However, this is rapidly changing. Modern browsers have evolved significantly, now offering improved processing capabilities and access to powerful APIs that streamline the way animations are rendered. Animations have become an integral part of user experience design that can significantly influence engagement and usability.