Table of Contents
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.
Let's begin!
Observations
- The cursor moves near some grid item.
- As soon as it reaches a minimum distance from the item, the borders of those nearby items are highlighted.
- 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
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
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:
- Either we check for all points on the circumference
- 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
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 🙃
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
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 notmap()
orforEach()
? *🤔 -
what is this
reduce()
method ?😓
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
- function that is executed for each item in the array and returns the updated result by performing some operation over the previous result.
- 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
- The accumulator (this will be the result up to the current item)
- The current item of the array
- index of the item (optional argument)
- array itself on which we are looping over (optional argument)
So, what happens inside reduce is that
- 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]
- Now, for the second item in the array, this process repeats,
So the accumulator becomes
[e1,e2]
- 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 toaccumulator
, so we simply return theaccumulator
as it is. So our accumulator remains [e1,e2] only.
Why don't we use map()
or forEach()
There are 2 reasons for this
- If we don't return anything in the
map
function, it will save anundefined
value in the result array and to remove those we would have to use thefilter()
method 🥴 and we don't want to reiterate the array just for that. - 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 thereduce()
method exists for such use cases so it is more appropriate to usereduce()
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
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...
- Also, we want to clear any existing grid effects applied to a button, when we enter that button
- 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 😉.
Additional Resources
You can refer to the additional resources mentioned below for a better understanding of CSS and JS.
Top comments (21)
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 👋
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 useaddEventListener
method for that particular event.For reference, I have done
console.dir()
of a DOM element to check the sameI Hope this helps 😉
'onmousemove' in HTMLElement.prototype
intrue
, and every event should be added viaaddEventListener
to avoid conflicts with other possible events.b.onmouseleave
should beb.addEventListener('mouseleave', ...)
and the other too, or you can usehandleEvent
with an object right away.As summary, this is how I would've written that:
Nice ✨
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 ;-)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
not sure this answers your question: webreflection.medium.com/dom-handl... 👋
wonderful jash 👍
Thankyou😇
Hi,
first of all thank you so much for making the code for this effect and the detailed explanation of the code.
I want to apply the same effect in my project but have to do some modifications, one of which is that i want to add an image also with the text in each grid item but after doing that the affect is not working properly.
The problem is that if i hover over any grid item that contains the image, there are some points at which the hover effect doesnt work.
I dont know what is the problem or what to change in code to make it work properly.
Please help!!!
Problem 1:
Problem 2:
Hey Jagrit, Thanks for the feedback. I had also encountered same problem sometime after publishing these articles. The solution I used is that to use a div as the win-grid-item on top of our actual grid item.
If you are trying to implement it in react then you can try the component that I have published on npm - npmjs.com/package/react-win-grid
What is the win-grid-item?
Can you please give a detailed solution to this?
My bad, by win-grid-item, I meant any item inside the win-grid class. These are just the component names that I used for my npm package.
Here is the idea in simpler terms:
Originally, the items that we have in our grid are primitive elements and not composite i.e. they don't have any child elements inside them. So the logic that I have given in the article tries to change the background of the button.
Now, when we have composite elements in our win-grid class, like having a div and inside that having other elements, we want to show the gradient on top of that composite item. So what you can do in this case is that place a transparent div element on top of each composite item in your grid.
A simple analogy would be that the grid is a collection of boxes, and each box has some items in it. You want the spotlight to highlight the edges of the boxes and the top of the boxes. So what you do is that you cover each box with a transparent lid and now write your code to apply the spotlight effects on the edges and surface of lid and apply a blending mode so that it will look like the effect is applied to whole box and not just the lid.
You can have a look at these two files where I have done the same thing for my react component
Github link
You can refer to the ".hoverlay" class here. It is analogous to the transparent lid. If you are not very familiar with react then the whole code might confuse you but the idea is the same that I've explained above.
Let me know if you have any further questions.
Great explanation!
Thankyou professor 🤓
Waah Gopani Ji Waah! XD
Thanks for explaining every aspect of the code, including why you didn't use map() or forEach(). Great post!
Thankyou Gandhi Ji 🙌
Great logic and explanation ..never thought that such a simple ui would be that difficult to make.
Less is more 😂
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.
Thanks a lot🤟