DEV Community

Cover image for Windows 10 calendar hover effect using HTML, CSS, and vanilla JS
Jash Gopani
Jash Gopani

Posted on

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

Table of Contents

  1. Introduction
  2. Observations
  3. Getting Started
  4. Fine Tuning Grid Effect
  5. Additional Resources

Introduction

Welcome back devs! In this 3rd and final part of this series, I will explain to you how you can create your own version of the windows 10 calendar. The implementation logic is 80% similar to the grid hover effect logic.
So, if at any point you feel as if you don't understand what's going on, I recommend that you first read Part 2 of this series and then return here. With that said, let us check the final output first!

ℹ This article is a little elaborative but that's for beginners, if you are already good at JS and if you know the Grid hover logic you can quickly go through the code to understand what's going on.

Calendar Codepen Final Output

Observations

  1. Undoubtedly, the Grid hover effect is used here, but more than one element's border is highlighted in each direction around the cursor i.e element behind an element is also highlighted
  2. The dates do not have Button hover effect
  3. The grid hover effect does not apply to the active date (today's date) element.
  4. The active Date has a gap between the border and background by default. If some other date is selected, the gap is eliminated.
  5. Clicked date which is a non-active date, will have only a colored border
  6. Border of the active element is illuminated

Getting Started

As you might have guessed, I will start with the grid effect code.

The First 7 elements of the grid are week names and rest dates. Since the calendar shows 42 dates at once, hence I have added 42 win-btn elements in win-grid. Some dates are inactive and one of them is active, so I have added classes accordingly.

HTML

<html>

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

<body>
  <h1>Windows 10 Calendar hover effect</h1>
  <div class="win-grid">
    <p class="week" id="1">Mo</p>
    <p class="week" id="2">Tu</p>
    <p class="week" id="3">We</p>
    <p class="week" id="4">Th</p>
    <p class="week" id="5">Fr</p>
    <p class="week" id="6">Sa</p>
    <p class="week" id="7">Su</p>
    <div class="win-btn win-btn-inactive" id="40">29</div>
    <div class="win-btn win-btn-inactive" id="41">30</div>
    <div class="win-btn win-btn-inactive" id="42">31</div>
    <div class="win-btn" id="1">1</div>
    <div class="win-btn" id="2">2</div>
    <div class="win-btn" id="3">3</div>
    <div class="win-btn" id="4">4</div>
    <div class="win-btn" id="5">5</div>
    <div class="win-btn" id="6">6</div>
    <div class="win-btn" id="7">7</div>
    <div class="win-btn" id="8">8</div>
    <div class="win-btn" id="9">9</div>
    <div class="win-btn" id="10">10</div>
    <div class="win-btn" id="11">11</div>
    <div class="win-btn" id="12">12</div>
    <div class="win-btn" id="13">13</div>
    <div class="win-btn" id="14">14</div>
    <div class="win-btn" id="15">15</div>
    <div class="win-btn" id="16">16</div>
    <div class="win-btn win-btn-active" id="17">17</div>
    <div class="win-btn" id="18">18</div>
    <div class="win-btn" id="19">19</div>
    <div class="win-btn" id="20">20</div>
    <div class="win-btn" id="21">21</div>
    <div class="win-btn" id="22">22</div>
    <div class="win-btn" id="23">23</div>
    <div class="win-btn" id="24">24</div>
    <div class="win-btn" id="25">25</div>
    <div class="win-btn" id="26">26</div>
    <div class="win-btn" id="27">27</div>
    <div class="win-btn" id="28">28</div>
    <div class="win-btn" id="29">29</div>
    <div class="win-btn" id="30">30</div>
    <div class="win-btn win-btn-inactive" id="31">1</div>
    <div class="win-btn win-btn-inactive" id="32">2</div>
    <div class="win-btn win-btn-inactive" id="33">3</div>
    <div class="win-btn win-btn-inactive" id="34">4</div>
    <div class="win-btn win-btn-inactive" id="35">5</div>
    <div class="win-btn win-btn-inactive" id="36">6</div>
    <div class="win-btn win-btn-inactive" id="37">7</div>
    <div class="win-btn win-btn-inactive" id="38">8</div>
    <div class="win-btn win-btn-inactive" id="39">9</div>
  </div>
</body>

</html>
Enter fullscreen mode Exit fullscreen mode

Inside CSS, we change the number of columns in the grid to 7 and add the following classes : win-btn-inactive,win-btn-active,win-btn-selected.

CSS

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

* {
  box-sizing: border-box !important;
  color: white;
  text-transform: capitalize !important;
  font-family: "Noto Sans JP", sans-serif;
  letter-spacing: 2px;
}

body {
  background-color: black;
  display: flex;
  flex-flow: column wrap;
  justify-content: center;
  align-items: center;
}

.win-grid {
  border: 1px solid white;
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  grid-gap: 0.2rem;
  align-items: stretch;
  text-align: center;
  padding: 2rem;
  cursor: default;
}

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

/* Today's Date */
.win-btn-active {
  background: red;
}

/* Other Month's Date */
.win-btn-inactive {
  color: #ffffff5f;
}

/* Clicked Date */
.win-btn-selected {
  border: 3px solid red;
}

button:focus {
  outline: none;
}

Enter fullscreen mode Exit fullscreen mode

And the JS code will be almost the same except for the win-btn event listeners. We don't need those anymore. Also, since we added more classes to the elements, we cannot just directly compare the className in the grid hover event... We need to check if the class exists in the element's classList.

JS

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

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) {
      console.log("cursor at ", x, y, "element at ", cx, cy, element.id);
      if (
        element.classList.contains("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

This is how our initial calendar looks like

Initial Calendar Effect

Fine Tuning Grid Effect

As you can see, the grid effect works but we need to fix some bugs and do some state management. Let us go through each bug and discuss its solution.

Problem 1 - Element very close to the cursor is not highlighted

Very strange right! When the cursor is very very close to an element, its target is the win-grid element only, so ideally all the nearby elements must be highlighted. But what is happening here, can you guess the cause?

grid vs calendar

For those who still did not get it, the offset value is larger than the nearby element and hence, the element which is shown in blue is not getting highlighted! To fix this we need to reduce the offset value to a closer one....but if the offset is less than the element's dimensions, how will it reach the nearby 8 elements?

Solution 1

What we can do is, we can target 2 points on each offset line instead of just targeting the endpoint. The first point might be very near to the center and the second will be the endpoint only.

Multiple points on radius

And while writing this article, I just realized that there is room for some optimization also! In grid effect, we were calculating 8 values, according to my new approach we would have to calculate 16 values! As you can see, we can skip some "first point" calculations i.e the points that are near to the center and whose main purpose is to detect extremely nearBy elements.
So we will only calculate 4 nearBy points, hence total 12 point calculations per mouse movement instead of 8.

Problem 2 - The gap between the border and background of active date

This might not sound like a big problem but think about it. How would you do it? The most obvious thought that comes to our mind is that wrap each win-btn element inside a div and apply border effects to the outer container element.
But doing this will increase the number of elements in our DOM, moreover, we will also have to change the elements which we are detecting, in our code.
So, every time we move the cursor, we would get a nearby win-btn element, and then we would have to change the style of its parent Element. We also need to add the scenario when the mouse moves over the container element and such minor event handling of new elements added to our DOM.
This way we are just adding more and more event listeners which can be avoided...

Solution 2

There is a CSS property, which helps us do exactly what we want. It is called background-origin.
According to MDN Docs, The background-origin CSS property sets the background's origin: from the border start, inside the border, or inside the padding.
The default value is border-box, which means that the background starts from where the border ends.
We will use content-box value because this will allow us to use the padding region of the box model as a gap between the border and the background!

Remaining logic

Now the only thing remaining is the minor state handling for the selected date. We need to remember the previously selected element so that when a new date is selected, we first clear the border of the previous element and add then add the border to our new element.
What we will do is we will create a CSS class that has the border styling and add or remove the class from the element as required.

/* Clicked Date */
.win-btn-selected {
  border: 3px solid red;
}
Enter fullscreen mode Exit fullscreen mode

If any date other than the active date is selected, the background of the active date expands till border (like its usual behaviour). So we will make a class for that also ; win-btn-active-unselected which will change the background-origin back to border-box.

/* Today's Date when some other date is clicked*/
.win-btn-active-unselected {
    background-origin: border-box;
}
Enter fullscreen mode Exit fullscreen mode

The Final Code

CSS

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

* {
  box-sizing: border-box !important;
  color: white;
  text-transform: capitalize !important;
  font-family: "Noto Sans JP", sans-serif;
  letter-spacing: 2px;
}

body {
  background-color: black;
  display: flex;
  flex-flow: column wrap;
  justify-content: center;
  align-items: center;
}

.win-grid {
  border: 1px solid white;
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  grid-gap: 0.2rem;
  align-items: stretch;
  text-align: center;
  padding: 2rem;
  cursor: default;
}

.win-btn {
  padding: 1rem;
  text-align: center;
  border-radius: 0px;
  border: 3px solid transparent;
  background-origin: content-box;
}

/* Today's Date */
.win-btn-active {
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 0.2rem;
    border: 3px solid red;
  background: center linear-gradient(red, red) no-repeat;
    background-origin: content-box;
}

/* Today's Date when some other date is clicked*/
.win-btn-active-unselected {
    background-origin: border-box;
}

/* Other Month's Date */
.win-btn-inactive {
  color: #ffffff5f;
}

/* Clicked Date */
.win-btn-selected {
  border: 3px solid red;
}

.win-btn:hover {
  border: 3px solid rgba(255, 255, 255, 0.4);
}

.win-btn-active:hover {
  border: 3px solid hsl(0, 90%, 75%);
}

.win-btn-selected:hover {
  border: 3px solid hsl(0, 70%, 50%) !important;
}

button:focus {
  outline: none;
}

Enter fullscreen mode Exit fullscreen mode

JS

const offset = 69;
const borderWidth = 3;
const angles = []; //in  rad
for (let i = 0; i <= 2; i += 0.25) {
  angles.push(Math.PI * i);
}
let nearBy = [];
let activeBtn = document.querySelector(".win-btn-active");
let lastClicked = null;

document.querySelectorAll(".win-btn").forEach((btn) => {
  btn.onclick = (e) => {
    //clear effects from last clicked date and set lastClicked to current item
    if (lastClicked) {
      lastClicked.classList.remove("win-btn-selected");
    }
    lastClicked = e.currentTarget;

    activeBtn.classList.toggle(
      "win-btn-active-unselected",
      e.currentTarget.id !== activeBtn.id
    );
    e.currentTarget.classList.add("win-btn-selected");
  };
});

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

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

body.addEventListener("mousemove", (e) => {
  let x = e.clientX; //x position of cursor.
  let y = e.clientY; //y position of cursor

  clearNearBy();

  nearBy = angles.reduce((acc, rad, index, arr) => {
    const offsets = [offset * 0.35, offset * 1.105];

    const elements = offsets.reduce((elementAccumulator, o, i, offsetArray) => {
      if (index % 2 === 0 && i === 0) return elementAccumulator;
      const cx = Math.floor(x + Math.cos(rad) * o);
      const cy = Math.floor(y + Math.sin(rad) * o);
      const element = document.elementFromPoint(cx, cy);
      // console.log("element at", x, y, cx, cy, offsets, (rad * 180) / Math.PI);
      if (
        element &&
        element.classList.contains("win-btn") &&
        !element.classList.contains("win-btn-active") &&
        !element.classList.contains("win-btn-selected") &&
        elementAccumulator.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.
        const gr = Math.floor(offset * 1.7);
        if (!element.style.borderImage)
          element.style.borderImage = `radial-gradient(${gr}px ${gr}px at ${bx}px ${by}px ,rgba(255,255,255,0.3),rgba(255,255,255,0.1),transparent ) 9 / ${borderWidth}px / 0px stretch `;
        console.log("element at", offsets, (rad * 180) / Math.PI, element);

        return [...elementAccumulator, element];
      }
      return elementAccumulator;
    }, []);

    return acc.concat(elements);
  }, []);
});

body.onmouseleave = (e) => {
  clearNearBy();
};
Enter fullscreen mode Exit fullscreen mode

Quick Code Explanation

  1. Instead of converting degrees to radians, I am directly calculating angles in radians (0, PI/4, PI/2, 3PI/4... 2PI).

  2. The win-btn's event handler takes care of the currently selected element. One small change I have done here is that I use theclassListproperty to add and remove classes instead of manually changing the CSS styles using thestyle` property because the properties we want to change have static values, unlike border-image which has radial gradient at the cursor position.

    classList.toggle()

    The classList.toggle() method removes the class from the element if the 2nd argument evaluates to false else adds the class to the element.

  3. Since at a given angle we check for elements at 2 points on the offset line (green lines in the figure above), I store the offset values into an array called offsets.
    I did this so that we can iterate over the 2 values and check for an element at each value. This way we can extend this method to calculate more than 2 points and detect more elements for a particular angle value; for this case,2 offset values are fine.
    So the offsets.reduce() method returns those 2 elements only. I have shifted the element selection and styling code inside the offsets.reduce() method only to avoid another iteration over elements just for styling them.
    If there are no elements at a particular angle then the elements array will be empty.

Note: The offset values I have used give an almost an original-like effect and are trial and error based. You can play around with those values to get the look you like.

  1. Finally just add the elements into the accumulator and return.

Thank you! 😁

With this, we come to the end of this series of Recreating Windows Effects

Feel free to post suggestions, doubts, or any other feedback in the comment section below. Also, please do let me know, how much easy or difficult it was for you to understand all 3 articles.

The End

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

Top comments (15)

Collapse
 
drumstix42 profile image
Mark

Pretty interesting approach. Ultimately, I think some cleverly implemented CSS borders/backgrounds with a moving "spotlight", is a much simpler (and, imo, better) approach. It achieves the same effect with less of a performance/overhead impact.

See here: codepen.io/gubb/pen/PdZqKy

It includes some different themes, and a few of the dates have a specialized transform to showcase a "selected" date transition/custom styling.

Collapse
 
jashgopani profile image
Jash Gopani

Yes, you're right! Thanks for pointing this out @drumstix42 . This can be done via some smart CSS. Even I found this codepen when I was looking for implementations. I played a bit with the code to understand how this actually works.

Why didn't I prefer the SCSS way?

I found that there is another layer created below the grid, and between those 2 grids, there is a spotlight that follows the cursor, and to hide that spotlight outside the calendar area, the grid is surrounded by 4 extra elements, if you remove those, then the spotlight will be visible outside the calendar also.
Imagine implementing this effect for a component library where you don't know what type of elements users are going to pass in your grid component...I don't prefer extra elements and hardcoded CSS which would be there just to cover up side effects.
Since the majority of beginners use CSS and you don't need a separate compiler for CSSI wanted to use that 😬

People reading this should know that,

The codepen you shared is just a different perspective of looking at the effect. The author of the codepen visualized the effect in 3d and in terms of layers, whereas I thought about it in a 2d plane. There can be another approach also. If you have any to share here, I would love to see those 😃

Collapse
 
drumstix42 profile image
Mark

Thanks for the follow up! Hopefully my original comment didn't come off as rude, as it wasn't meant to!
Good explanation, and it's always good to explore multiple avenues of approaching a problem.

Thread Thread
 
jashgopani profile image
Jash Gopani

It wasn't rude at all bro.😁

Collapse
 
ankkaya profile image
Ankkaya

Great work, bro

Collapse
 
the_riz profile image
Rich Winter

Dude, this is super cool and a great post-set! Thank you for sharing.

I do have some serious issues with your code.

  1. (From the earlier article - Never substitute a fancy div for a <button> if what you want is a button.)
  2. In CSS going button:focus { outline: none; }, while perhaps improving styling, kills keyboard navigation! Now you have a completely "inaccessible" calendar. Unless you replace that setting, there are no visual clues that the user has highlighted anything.
  3. Right now, as it stands, the mobile display is wonky for me.
  4. Perf wise maybe a bunch of transparent divs over a canvas to do the drawing? Because...
  5. Not knowing the original, I wonder how you would handle highlights a dragged range of dates...
  6. Of course, it is a matter of taste, but putting anything in the * CSS selector other than box-sizing:border-box; in a * selector is unusual. ESPECIALLY putting a text-transform: capitalize !important; or ANY !important selector is very odd. The html/body selectors are more appropriate. Even variable definitions usually live at :root
Collapse
 
jashgopani profile image
Jash Gopani

Hi @the_riz ,
Thank you for going through all the posts and giving your feedback 🙂
I did not get your points 3,4,5; if you could elaborate on that, please...
For the rest,

  1. My aim was to give effect to any non-input element...button/div/p/span etc. So I just used div for demonstration purpose and win-btn class name is just for analogy purpose. In actual apps, it can be any target element. So the purpose was not at all to replace the button; it was just for demo.
  2. Accessibility wise it is not a good practice to remove the outline, but again I didn't care about accessibility in this.
  3. More than a matter of taste, I was not building a big production application, this is just a small piece of code where I wanted everything to be in upper case..hence the approach.

This is definitely not the best CSS code, I agree...its just that I wanted to create this quickly and many things were trial and error based.😂
Hope you get my point (*Don't think of this reply as a rude one,🙈 as it isn't meant to *)

Collapse
 
jashgopani profile image
Jash Gopani

You can use this effect where items are arranged in grid like app drawer, calendar, calculator buttons...
Most importantly, I wanted to share my approach of implementing this effect because working on this help me learn new things :)

Collapse
 
sandesh3 profile image
Sandesh Goli

Mind-blowing...

Collapse
 
fischgeek profile image
fischgeek

Very cool you figured out how to recreate a part of Microsoft Fluent in vanilla web.

Collapse
 
jashgopani profile image
Jash Gopani

Yes..my primary purpose was to share the approach of creating the effect.😃

Collapse
 
mihirgandhi profile image
Mihir Gandhi

Great series Jash! Excited for your upcoming articles.

Collapse
 
jashgopani profile image
Jash Gopani

Thanks a lot😁

Collapse
 
afternoonpm profile image
AfterNoonPM

...(mind blown🤔)...

Some comments may only be visible to logged-in visitors. Sign in to view all comments.