DEV Community

loading...
Cover image for Windows 10 grid hover effect using HTML, CSS, and vanilla JS

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

jashgopani profile image Jash Gopani Updated on ・13 min read

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>
Enter fullscreen mode Exit fullscreen mode

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;
}


Enter fullscreen mode Exit fullscreen mode

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
});

Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

: 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;
  }, []);
Enter fullscreen mode Exit fullscreen mode
  • *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;
  }, []);
});

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
};

Enter fullscreen mode Exit fullscreen mode

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

Discussion (17)

Collapse
webreflection profile image
Andrea Giammarchi

interesting article, thanks, and just few quick notes:

nearBy.splice(0, nearBy.length)
could just be
nearBy.splice(0)

element.className
could rather be
element.classList.contains('win-btn')

last, but not least, it's not clear why some event is directly attached, and some is added via proper method ... anyway, thanks for the write up and the code 👋

Collapse
jashgopani profile image
Jash Gopani Author

Thanks, @webreflection for the splice suggestion. I will use that 😁.
I have almost completed the 3rd article of this series and I have already used the classList property there since I wanted to show both the approaches to the readers.

Coming to your query about direct event vs methods, the DOM element does not have a property called onmousemove . That's why I had to use addEventListener method for that particular event.

For reference, I have done console.dir() of a DOM element to check the same

console.dir

I Hope this helps 😉

Collapse
webreflection profile image
Andrea Giammarchi • Edited

'onmousemove' in HTMLElement.prototype in true, and every event should be added via addEventListener to avoid conflicts with other possible events.

b.onmouseleave should be b.addEventListener('mouseleave', ...) and the other too, or you can use handleEvent with an object right away.

As summary, this is how I would've written that:

const winBtn = {
  handleEvent(e) {
    this[`on${e.type}`](e);
  },
  onmouseleave({currentTarget}) {
    Object.assign(currentTarget.style, {
      background: "black",
      borderImage: null
    });
    currentTarget.border = "1px solid transparent";
  },
  onmouseenter() {
    clearNearBy();
  },
  onmousemove({currentTarget, clientX, clientY}) {
    currentTarget.border = "1px solid transparent";
    const {left, top} = currentTarget.getBoundingClientRect();
    const x = clientX - left; //x position within the element.
    const y = clientY - top; //y position within the element.
    Object.assign(currentTarget.style, {
      background:  `radial-gradient(circle at ${x}px ${y}px , rgba(255,255,255,0.25),rgba(255,255,255,0) )`,
      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 `
    });
  }
};

document.querySelectorAll(".win-btn").forEach((b) => {
  for (const type of ['mouseleave', 'mouseenter', 'mousemove'])
    b.addEventListener(type, winBtn);
});
Enter fullscreen mode Exit fullscreen mode
Thread Thread
jashgopani profile image
Thread Thread
webreflection profile image
Andrea Giammarchi

if you look closer, you also have one object as listener, with 4 methods, instead of N x 3 functions per each .win-btn on the page ... it's both a RAM and a CPU win ;-)

Thread Thread
jashgopani profile image
Jash Gopani Author • Edited

Do you always prefer this syntax over normal syntax? Since you have a ton of experience in JS, I would like to know what more improvisations can be made 😬. Please give your feedback on part 3 also

Thread Thread
webreflection profile image
Andrea Giammarchi

not sure this answers your question: webreflection.medium.com/dom-handl... 👋

Collapse
professor9833 profile image
Professor9833

Great explanation!

Collapse
jashgopani profile image
Jash Gopani Author

Thankyou professor 🤓

Collapse
mihirgandhi profile image
Mihir Gandhi

Waah Gopani Ji Waah! XD
Thanks for explaining every aspect of the code, including why you didn't use map() or forEach(). Great post!

Collapse
jashgopani profile image
Jash Gopani Author

Thankyou Gandhi Ji 🙌

Collapse
amoldalwai profile image
amoldalwai

Great logic and explanation ..never thought that such a simple ui would be that difficult to make.

Collapse
jashgopani profile image
Jash Gopani Author

Less is more 😂

Collapse
namanvyas profile image
Naman vyas

wonderful jash 👍

Collapse
jashgopani profile image
Jash Gopani Author

Thankyou😇

Collapse
arnavgu25774294 profile image
Arnav Gupta

A great descriptive article. Very well written and the memes in the middle provided a good laugh and made me enjoy the article a bit more.

Collapse
jashgopani profile image
Jash Gopani Author

Thanks a lot🤟

Forem Open with the Forem app