1. Web Design
  2. UX/UI
  3. Navigation

How to Build a Shifting Underline Hover Effect With CSS and JavaScript

Scroll to top

In today’s tutorial, we’re going to use a little bit of CSS and JavaScript to create a fancy menu hover effect. It’s not a complicated end result, yet building it will be a great opportunity to practice our front-end skills.

Without further intro, let’s check out what we’ll be building:

The Markup

We start with some very basic markup; a nav element which contains the menu and an empty span element:

1
<nav class="mynav">
2
  <ul>
3
    <li>
4
      <a href="">Home</a>
5
    </li>
6
    <li>
7
      <a href="">About</a>
8
    </li>
9
    <li>
10
      <a href="">Company</a>
11
    </li>
12
    <li>
13
      <a href="">Work</a>
14
    </li>
15
    <li>
16
      <a href="">Clients</a>
17
    </li>
18
    <li>
19
      <a href="">Contact</a>
20
    </li>
21
  </ul>
22
</nav>
23
24
<span class="target"></span>

The CSS

With the markup ready, next we specify some basic styles for the related elements:

1
.mynav ul {
2
  display: flex;
3
  justify-content: center;
4
  flex-wrap: wrap;
5
  list-style-type: none;
6
  padding: 0;
7
}
8
9
.mynav li:not(:last-child) {
10
  margin-right: 20px;
11
}
12
13
.mynav a {
14
  display: block;
15
  font-size: 20px;
16
  color: black;
17
  text-decoration: none;
18
  padding: 7px 15px;
19
}
20
21
.target {
22
  position: absolute;
23
  border-bottom: 4px solid transparent;
24
  z-index: -1;
25
  transform: translateX(-60px);
26
}
27
28
.mynav a,
29
.target {
30
  transition: all .35s ease-in-out;
31
}

Notice that the span element (.target) is absolutely positioned. As we’ll see in a moment, we’ll use JavaScript to determine its exact position. In addition, it should appear behind the menu links, so we give it a negative z-index.

The JavaScript

At this point, let’s focus our attention on the required JavaScript. To begin with, we target the desired elements. We also define an array of colors which we’ll use later.

1
const target = document.querySelector(".target");
2
const links = document.querySelectorAll(".mynav a");
3
const colors = ["deepskyblue", "orange", "firebrick", "gold", "magenta", "black", "darkblue"];

Events

Next we listen for the click and mouseenter events of the menu links. 

When the click event happens, we prevent the page from reloading. Of course, this works in our case because all links have an empty href attribute. In a real project however, each of the menu links would likely open a different page.  

Most importantly, as soon as the mouseenter event fires, the mouseenterFunc callback function is executed:

1
for (let i = 0; i < links.length; i++) {
2
  links[i].addEventListener("click", (e) => e.preventDefault());
3
  links[i].addEventListener("mouseenter", mouseenterFunc);
4
}

mouseenterFunc

The body of the mouseenterFunc function looks like this:

1
function mouseenterFunc() {
2
  for (let i = 0; i < links.length; i++) {
3
    if (links[i].parentNode.classList.contains("active")) {
4
      links[i].parentNode.classList.remove("active");
5
    }
6
    links[i].style.opacity = "0.25";
7
  }
8
  
9
  this.parentNode.classList.add("active");
10
  this.style.opacity = "1";
11
  
12
  const width = this.getBoundingClientRect().width;
13
  const height = this.getBoundingClientRect().height;
14
  const left = this.getBoundingClientRect().left;
15
  const top = this.getBoundingClientRect().top;
16
  const color = colors[Math.floor(Math.random() * colors.length)];
17
18
  target.style.width = `${width}px`;
19
  target.style.height = `${height}px`;
20
  target.style.left = `${left}px`;
21
  target.style.top = `${top}px`;
22
  target.style.borderColor = color;
23
  target.style.transform = "none";
24
}

Inside this function we do the following:

  1. Add the active class to the immediate parent (li) of the target link.
  2. Decrease the opacity from all menu links, apart from the “active” one.
  3. Use the getBoundingClientRect method to retrieve the size of the associated link and its position relative to the viewport. 
  4. Get a random color from the aforementioned array and pass it as value to the border-color property of the span element. Remember, its initial property value is set to transparent.
  5. Assign the values extracted from the getBoundingClientRect method to the corresponding properties of the span element. In other words, the span tag inherits the size and the position of the link that’s being hovered over.
  6. Reset the default transformation applied to the span element. This behavior is only important the first time we hover over a link. In this case, the transformation of the element goes from transform: translateX(-60px) to transform: none. That gives us a nice slide-in effect.

If Active

It's important to note that the code above is executed every time we hover over a link. It therefore runs when we hover over an “active” link as well. To prevent this behavior, we wrap the code above inside an if statement:

1
function mouseenterFunc() {
2
  if (!this.parentNode.classList.contains("active")) {
3
    // code here

4
  }
5
}

So far, our demo looks as follows:

Nearly, but Not Quite

So, everything seems to work as expected, right? Well, that's not true because if we scroll through the page, or resize the viewport, and then try to select a link, things get messy. Specifically, the position of the span element becomes incorrect.

Play around with the full page demo (make sure you’ve added enough dummy content) to see what I mean.

To solve it, we have to calculate how far we’ve scrolled from the top of the window and add this value to the current top value of the target element. In the same way we should calculate how far the document has been scrolled horizontally (just in case). The resulting value is added to the current left value of the target element.

Here are the two lines of code that we update:

1
const left = this.getBoundingClientRect().left + window.pageXOffset;
2
const top = this.getBoundingClientRect().top + window.pageYOffset;

Keep in mind that all the code above is executed as soon as the browser processes the DOM and finds the relevant script. Again, for your own implementations and designs you might want to run this code when the page loads, or something like that. In such a scenario, you’ll have to embed it within an event handler (e.g. load event handler).

Viewport

The last thing we have to do is to ensure that the effect will still work as we resize the browser window. To accomplish this, we listen for the resize event and register the resizeFunc event handler.

1
window.addEventListener("resize", resizeFunc);

Here’s the body of this handler:

1
function resizeFunc() {
2
  const active = document.querySelector(".mynav li.active");
3
  
4
  if (active) {
5
    const left = active.getBoundingClientRect().left + window.pageXOffset;
6
    const top = active.getBoundingClientRect().top + window.pageYOffset;
7
8
    target.style.left = `${left}px`;
9
    target.style.top = `${top}px`;
10
  }
11
}

Inside the function above, we do the following:

  1. Check if there’s a menu list item with the class of active. If there is such an element, that states that we’ve already hovered over a link.
  2.  Get the new left and top properties of the “active” item along with the related window properties and assign them to the span element. Note that we retrieve the values only for the properties that change during the resize event. That means, there’s no need to recalculate the width and height of the menu links.

Browser Support

The demo works well in all recent browsers. If you encounter any issues though, let me know in the comments below. Also, as you’ve possibly noticed, we use Babel to compile our ES6 code down to ES5.

Conclusion

In this tutorial we went through the process of creating a simple, yet interesting menu hover effect.

I hope you enjoyed what we built here and took inspiration for developing even more powerful menu effects like the one appearing (at the time of writing) in the Stripe site.

Have you ever created something similar? If so, be sure to share with us the challenges you faced.

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Web Design tutorials. Never miss out on learning about the next big thing.
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.