technical

Animations - Liquid background hover effect

Subtle animations can make a big difference in user experience.
Sep 12, 2024
6 min read

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:

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:

Active Index: 0

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.

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.

Did you like the post?