Windows 10 grid hover effect using HTML, CSS, and vanilla JS

Windows 10 grid hover effect using HTML, CSS, and vanilla JS

Table of Contents

  1. Introduction
  2. Observations
  3. Getting Started
  4. The Crux
    1. Finding nearby elements to cursor
    2. How to Calculate nearby points
    3. Selecting and Styling the right elements
    4. The Code
    5. The Reduce Method
  5. Handling Edge Cases
  6. Additional Resources

Note: The aim of writing this article is that readers of all skill levels understand maximum content. I have explained all the necessary basic concepts used in the effect in brief in this article; So please do not ignore the article by its length. Instead, if you are not a beginner, I request you to go through the content and provide your valuable feedback :)

Introduction

Hello there, if you've arrived here after reading my previous post, I'd like to congratulate you as you already understand half of the code used in this effect👏. I highly suggest that you read the first part (Button hover effect) because I explain some essential CSS properties used in all these effects.

You can have a look at the final grid hover effect below.

Final Grid Hover Effect

Let's begin!

Observations

  1. The cursor moves near some grid item.
  2. As soon as it reaches a minimum distance from the item, the borders of those nearby items are highlighted.
  3. The intensity of highlight on the border of items is based on the position of the cursor.

So, it is obvious that we will be working with mouse events, especially the mousemove event.

Getting Started

I started the basic setup by forking my own implementation of Windows button hover effect codepen and then adding the mouse events to the win-grid element. Here is the initial code.

HTML

<html>

<head>
  <title>Windows 10 grid hover effect</title>
</head>

<body>
  <h1>Windows 10 Button & Grid Hover Effect</h1>
  <div class="win-grid">
    <div class="win-btn" id="1">This is a windows hoverable item inside windows grid</div>
    <div class="win-btn" id="2">This is a windows hoverable item inside windows grid</div>
    <div class="win-btn" id="3">This is a windows hoverable item inside windows grid</div>
    <div class="win-btn" id="4">This is a windows hoverable item inside windows grid</div>
    <div class="win-btn" id="5">This is a windows hoverable item inside windows grid</div>
    <div class="win-btn" id="6">This is a windows hoverable item inside windows grid</div>
    <div class="win-btn" id="7">This is a windows hoverable item inside windows grid</div>
    <div class="win-btn" id="8">This is a windows hoverable item inside windows grid</div>
    <div class="win-btn" id="9">This is a windows hoverable item inside windows grid</div>
  </div>

</body>

</html>

CSS

@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@100&display=swap");

* {
  box-sizing: border-box;
  color: white;
  font-family: "Noto Sans JP", sans-serif;
}
body {
  background-color: black;
  display: flex;
  flex-flow: column wrap;
  justofy-content: center;
  align-items: center;
}

.win-grid {
  border: 1px solid white;
  letter-spacing: 2px;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  align-items: stretch;
  text-align: center;
  grid-gap: 1rem;
  padding: 5rem;
}

.win-btn {
  padding: 1rem 2rem;
  text-align: center;
  border: none;
  border-radius: 0px;
  border: 1px solid transparent;
}

button:focus {
  outline: none;
}

JS

document.querySelectorAll(".win-btn").forEach((b) => {

  b.onmouseleave = (e) => {
    e.target.style.background = "black";
    e.target.style.borderImage = null;
  };

  b.addEventListener("mousemove", (e) => {
    const rect = e.target.getBoundingClientRect();
    const x = e.clientX - rect.left; //x position within the element.
    const y = e.clientY - rect.top; //y position within the element.
    e.target.style.background = `radial-gradient(circle at ${x}px ${y}px , rgba(255,255,255,0.2),rgba(255,255,255,0) )`;
    e.target.style.borderImage = `radial-gradient(20% 75% at ${x}px ${y}px ,rgba(255,255,255,0.7),rgba(255,255,255,0.1) ) 1 / 1px / 0px stretch `;
  });
});


const body = document.querySelector(".win-grid");
body.addEventListener("mousemove", (e) => {
   //effect logic here
});

This is how our output looks at this point

Initial code output

A quick explanation for the above code:

HTML code is pretty simple, a container div which will be the grid, and inside it are the items. In CSS, I have used a CSS grid to layout the items, so that the design remains responsive. The grid layout has 3 items, the grid has the class win-grid and the grid items are of class win-btn. JS is the button hover effect code. For a detailed explanation read this.

Now starts the interesting part!

The Crux

Note: This is my logic and there can be a different approach for implementing this effect but after looking at existing implementations available online I can assure you that my approach is the clean, least complicated, and scalable unlike other hardcoded ones 😉.

When the cursor comes inside the grid area, we need elements surrounding the cursor up to a particular distance. I refer to this radius or distance value as offset in my code. The bad news is that there is no method in JS to find elements in a certain region, but the good news is that there exists a method to find elements given a coordinate!

The method is document.elementFromPoint(x,y); It returns the topmost element falling under the coordinate passed as arguments. So if the coordinates are valid, then the method will return the body or some other element inside the body.

Your immediate question would be how exactly do we use this method to find surrounding nearby elements and what coordinates do we pass?

To understand this, have a look below.

Finding nearby elements to cursor

Cursor region diagram

From the figure, you might have guessed that we will check for points on the circumference of the circular region. That's absolutely correct!

We have 2 approaches from here:

  1. Either we check for all points on the circumference
  2. We skip some points

Obviously, option 2 looks less complicated; but which points to check for and which to skip? Since the max number of elements inside the grid, near the cursor, will be 4, we can check in all 8 directions around the cursor just like we do in real life!

How to Calculate nearby points

8 direction points

Since these points lie on the circumference of the circle, we will use simple vector mathematics to find them. So if p(x,y) is a point on the circumference of a circle on origin, with radius r, at a particular angle from the X-axis, the coordinates are calculated as follows

px = r*cos(angle)
py = r*sin(angle)

Note : angle is in radians i.e (degrees * PI / 180)

You can directly calculate these points, by simple logic (x-offset,y) for left, (x+offset,y) for right, and so on…But that would be too much hardcoding. Initially, I had gone for this approach and realized that if I want to increase or decrease the number of points around the cursor position, I had to declare or comment out lines of code, and that way we would not be writing very efficient code 🙃

Shift of origin of cursor region

Since the cursor is not going to be on the origin, we need to add the x and y distance from the origin to our coordinates px and py (Refer to the diagram above). Hence our new coordinates of the point on circumference become cx,cy (I call it changed x and y)

So the formula changes to

cx = x + r*cos(angle)
cy = y + r*sin(angle)

//where x,y refers to the current position of the cursor on the screen

: The origin of the screen is the top left corner and the left edge is the positive Y-axis and the top edge is the positive X-axis.

Selecting and Styling the right elements

Now, since we know how to find those 8 points, we will find elements on those points. We check if the element is not null, then check if its class is win-btn or not, and also, we need to check if the element already exists in the nearBy array or not. We only move ahead with the element if it does not exist in the nearBy array; then we finally apply border-image to the element. Why don't we save the elements first then loop over the array again...that would be donkey work tbh.

The exists in nearBy array check is required because the mouseover event triggers every time the cursor is moved and our logic will be fired every time the event fires. So we need to ensure that we are not saving the same elements again and again.

Now calculating the border image is already explained in the previous article, so I won't explain it here again.

If the above explanation is not making sense to you, have a look at the code below.

Some readers at this point are like

meme

Here you go 😜

The Code

//generate the angle values in radians
const angles = [];
for (let i = 0; i <= 360; i += 45) {
  angles.push((i * Math.PI) / 180);
}

//for each angle, find and save elements at that point
let nearBy = [];
nearBy = angles.reduce((acc, rad, i, arr) => {
    //find the coordinate for current angle
    const cx = Math.floor(x + Math.cos(rad) * offset);
    const cy = Math.floor(y + Math.sin(rad) * offset);
    const element = document.elementFromPoint(cx, cy);

    if (element !== null) {
      ;
      if (
        element.className === "win-btn" &&
        acc.findIndex((ae) => ae.id === element.id) < 0
      ) {
        const brect = element.getBoundingClientRect();
        const bx = x - brect.left; //x position within the element.
        const by = y - brect.top; //y position within the element.
        if (!element.style.borderImage)
          element.style.borderImage = `radial-gradient(${offset * 2}px ${
            offset * 2
          }px at ${bx}px ${by}px ,rgba(255,255,255,0.7),rgba(255,255,255,0.1),transparent ) 9 / 1px / 0px stretch `;
        return [...acc, element];
      }
    }
    return acc;
  }, []);
  • What code is this? 🥴
  • Why is he using reduce()and why not map() or forEach()? 🤔
  • what is this reduce() method ?😓

zakir meme

Just think what all steps we want to follow... For each angle in the angles array,

1. We want to find an element from the coordinates.
2. Apply style to the element if valid
3. Save the element on which style was applied into the `nearBy` array

So after processing each angle of the angle array, we want a single result i.e an array containing all nearBy elements which then, we store in the nearBy array.

In such scenarios where we want a single output after performing some operation on each item of an array, we use the reduce() method.

The Reduce Method

It takes 2 arguments

  1. function that is executed for each item in the array and returns the updated result by performing some operation over the previous result.
  2. variable (generally referred to as accumulator) that is equal to the latest result returned by the function mentioned above

The first argument i.e the function

This has several arguments

  1. The accumulator (this will be the result up to the current item)
  2. The current item of the array
  3. index of the item (optional argument)
  4. array itself on which we are looping over (optional argument)

So, what happens inside reduce is that

  1. It starts with the first item of the angle array. The accumulator has the initial value that is set in our code (In our case, it is an empty array). The current index is 0 and inside our function, We find an element based on the current angle and apply CSS to it (if applicable), and finally what we do is we return a new array with existing items of the accumulator (which do not exist at this point because the accumulator is empty) and our new element lets say e1 i.e [...acc, element].

So our updated accumulator is [e1]

  1. Now, for the second item in the array, this process repeats, So the accumulator becomes [e1,e2]
  2. and this goes on till we reach the end of the array. 4.Let's say if we get an element e3 which is win-grid itself, we don't want to add it to accumulator, so we simply return the accumulator as it is. So our accumulator remains [e1,e2] only.

Why don't we use map() or forEach()

There are 2 reasons for this

  1. If we don't return anything in the map function, it will save an undefined value in the result array and to remove those we would have to use the filter() method 🥴 and we don't want to reiterate the array just for that.
  2. The forEach method does not return any value, it will run a function for each item and we will have to push items manually into the nearby array which is not incorrect but the reduce() method exists for such use cases so it is more appropriate to use reduce() here.

That was a lot !!!

Let's have a look at the code and output at this point.

const offset = 69;
const angles = []; //in deg
for (let i = 0; i <= 360; i += 45) {
  angles.push((i * Math.PI) / 180);
}
let nearBy = [];

/*Effect #1 - https://codepen.io/struct_dhancha/pen/QWdqMLZ*/
document.querySelectorAll(".win-btn").forEach((b) => {

  b.onmouseleave = (e) => {
    e.target.style.background = "black";
    e.target.style.borderImage = null;
    e.target.border = "1px solid transparent";
  };

  b.addEventListener("mousemove", (e) => {
    e.stopPropagation();
    e.target.border = "1px solid transparent";
    const rect = e.target.getBoundingClientRect();
    const x = e.clientX - rect.left; //x position within the element.
    const y = e.clientY - rect.top; //y position within the element.
    e.target.style.background = `radial-gradient(circle at ${x}px ${y}px , rgba(255,255,255,0.25),rgba(255,255,255,0) )`;
    e.target.style.borderImage = `radial-gradient(20% 65% at ${x}px ${y}px ,rgba(255,255,255,0.7),rgba(255,255,255,0.7),rgba(255,255,255,0.1) ) 9 / 2px / 0px stretch `;
  });
});

const body = document.querySelector(".win-grid");

body.addEventListener("mousemove", (e) => {
  const x = e.x; //x position within the element.
  const y = e.y; //y position within the element.

  nearBy = angles.reduce((acc, rad, i, arr) => {
    const cx = Math.floor(x + Math.cos(rad) * offset);
    const cy = Math.floor(y + Math.sin(rad) * offset);
    const element = document.elementFromPoint(cx, cy);

    if (element !== null) {

      if (
        element.className === "win-btn" &&
        acc.findIndex((ae) => ae.id === element.id) < 0
      ) {
        const brect = element.getBoundingClientRect();
        const bx = x - brect.left; //x position within the element.
        const by = y - brect.top; //y position within the element.
        if (!element.style.borderImage)
          element.style.borderImage = `radial-gradient(${offset * 2}px ${
            offset * 2
          }px at ${bx}px ${by}px ,rgba(255,255,255,0.7),rgba(255,255,255,0.1),transparent ) 9 / 1px / 0px stretch `;
        return [...acc, element];
      }
    }
    return acc;
  }, []);
});

Here is the output

PartiallyWorking Grid gif (1)

So as you can see, we are successful in detecting and highlighting nearby elements 🎉. But, we must not forget to clear the previously applied effects when the mouse moves. This way, every time the mouse moves, the elements which were highlighted at the previous position are changed back to their original transparent border state and then we calculate all the nearby elements again from fresh and apply effects to the valid ones! And yes, do not forget to clear the previously saved nearBy elements else your cursor is at a new location and the current nearBy and previous nearBy both elements will be highlighted 😂 which would be not-so-pleasing.

So 2 things to do, remove all nearBy elements and border-image on them. We do this, just before calculating the new nearBy elements.

//inside the event listener

nearBy.splice(0, nearBy.length).forEach((e) => (e.style.borderImage = null));

//reduce method below

This one line of code does the 2 things I said. The splice() method takes a starting index and the number of items to be removed from that starting index, including the starting index and it modifies the original array. Hence after splice() operation, our nearBy array is empty. The splice() method returns an array containing all the items which were removed. So we iterate over that array and remove the border-image of all those elements!

And we are almost done...

Handling Edge Cases

Just some small edge cases to cover...

  1. Also, we want to clear any existing grid effects applied to a button, when we enter that button
  2. Clear all effects when the cursor leaves win-grid

For case 1,

clear nearBy array in mouseenter event of win-btn !

For case 2,

clear nearBy array in mouseleave event of win-grid !

Since clearing nearby is performed multiple times, I have shifted that code to a method clearNearBy() and I call that wherever clearing is to be done.

And that is finally all the code

const offset = 69;
const angles = []; //in deg
for (let i = 0; i <= 360; i += 45) {
  angles.push((i * Math.PI) / 180);
}
let nearBy = [];

function clearNearBy() {
  nearBy.splice(0, nearBy.length).forEach((e) => (e.style.borderImage = null));
}

/*Effect #1 - https://codepen.io/struct_dhancha/pen/QWdqMLZ*/
document.querySelectorAll(".win-btn").forEach((b) => {

  b.onmouseleave = (e) => {
    e.target.style.background = "black";
    e.target.style.borderImage = null;
    e.target.border = "1px solid transparent";
  };

  b.onmouseenter = (e) => {
    clearNearBy();
  };

  b.addEventListener("mousemove", (e) => {
    e.target.border = "1px solid transparent";
    const rect = e.target.getBoundingClientRect();
    const x = e.clientX - rect.left; //x position within the element.
    const y = e.clientY - rect.top; //y position within the element.
    e.target.style.background = `radial-gradient(circle at ${x}px ${y}px , rgba(255,255,255,0.25),rgba(255,255,255,0) )`;
    e.target.style.borderImage = `radial-gradient(20% 65% at ${x}px ${y}px ,rgba(255,255,255,0.7),rgba(255,255,255,0.7),rgba(255,255,255,0.1) ) 9 / 2px / 0px stretch `;
  });
});

const body = document.querySelector(".win-grid");

body.addEventListener("mousemove", (e) => {

  const x = e.x; //x position within the element.
  const y = e.y; //y position within the element.

  clearNearBy();
  nearBy = angles.reduce((acc, rad, i, arr) => {
    const cx = Math.floor(x + Math.cos(rad) * offset);
    const cy = Math.floor(y + Math.sin(rad) * offset);
    const element = document.elementFromPoint(cx, cy);

    if (element !== null) {

      if (
        element.className === "win-btn" &&
        acc.findIndex((ae) => ae.id === element.id) < 0
      ) {
        const brect = element.getBoundingClientRect();
        const bx = x - brect.left; //x position within the element.
        const by = y - brect.top; //y position within the element.
        if (!element.style.borderImage)
          element.style.borderImage = `radial-gradient(${offset * 2}px ${
            offset * 2
          }px at ${bx}px ${by}px ,rgba(255,255,255,0.7),rgba(255,255,255,0.1),transparent ) 9 / 1px / 0px stretch `;
        return [...acc, element];
      }
    }
    return acc;
  }, []);
});

body.onmouseleave = (e) => {
  clearNearBy();
};

If you have reached here then a big Thankyou 🙏 for completing this article.

Feel free to comment if you have any questions or issues and I'll try to help you!😁

Be ready for my next article as it going to be about creating the Windows 10 Calendar effect using the concepts I explained in these 2 articles. Do not forget to share this article with your dev friends 😉.

image

Additional Resources

You can refer to the additional resources mentioned below for a better understanding of CSS and JS.

  1. MDN Docs - CSS
  2. MDN Docs - JavaScript
  3. CSS Tricks