A shiny-on-hover effect that follows your mouse (CSS) ✨

Ben Holmes - Jan 28 '21 - - Dev Community

Hover states are probably the most fun a developer can have when a designer isn't looking. You've seen the basics at this point; fade-ins, growing and shrinking, color shifts, animated rainbow gradients, etc etc etc.

But there was one animation that inspired me recently (props to Keyframers for shouting it out!)

This isn't some "static" hover state that always looks the same. It actually tracks your mouse moment to make the page even more interactive. This seemed like such a cool idea... that we threw it all over our Hack4Impact site 😁

So let's explore

  • 🎈 Why CSS variables can help us
  • ✨ How we style our button
  • 🪤 How we map mouse movements to a metallic shine
  • 🔨 How to adapt this animation to any UI framework

Onwards!

Our end goal

Demo of buttons shining on hover on hack4impact.org website

The effect is pretty simple on the surface. Just shift the color a little bit whenever you hover over the button, plus a little circular gradient for a "metallic" sheen.

But there's a bit of added spice that CSS can't pull off on its own: We need to track your cursor position to make this interactive! Luckily, this has gotten a lot easier over the years; You won't even need a UI framework or state management to pull it off 👀

🎈 Brief primer on CSS variables

In case you haven't heard, CSS variables are kind of taking web development by storm right now. They're a bit like those $ variables preprocessors like SASS and LESS let you pull off, but with one huge benefit: you can change the value of these variables at runtime using JavaScript 😱

Let's see a simple example. Say we want to make a balloon pump, where you hit a button as fast as you can to "inflate" an HTML-style balloon.

If we didn't know anything about CSS variables, we'd probably do some style manipulation straight from JavaScript. Here's how we'd pump up a balloon using the transform property:

const balloon = document.querySelector('.balloon');
// make the balloon bigger by 50%
balloon.style.transform = 'scale(1.5)';
Enter fullscreen mode Exit fullscreen mode

Or, to make the balloon just a little bit bigger on every button click:

...
const pump = document.querySelector('.pump')
// keep track of the balloon's size in a JS variable
let size = 1;
pump.addEventListener('click', () => {
  size += 0.1;
    balloon.style.transform = `scale(${size})`;
})
Enter fullscreen mode Exit fullscreen mode

There's nothing wrong with this so far. But it has some growing pains:

  1. We need to keep track of a CSS property (the balloon's scale size) using a JS variable. This could ahem balloon into a suite of state variables overtime as we animate more elements throughout our app.
  2. We're writing our CSS using strings. This leaves a sour taste in my mouth personally, since we loose all our syntax highlighting + editor suggestions. It can also get nasty to maintain when we want that size variable in other parts of our styles. For example, what if we wanted to change the background-position as the balloon inflates? Or the height and width? Or some linear-gradient with multiple color positions?

CSS variables to the rescue

As you may have guessed, we can store this size from our code as a CSS variable!

We can use the same .style attribute as before, this time using the setProperty function to assign a value:

let size = 1;
pump.addEventListener('click', () => {
  size += 0.1;
    balloon.style.setProperty('--size', size);
})
Enter fullscreen mode Exit fullscreen mode

Then, slide that variable into our transform property from the CSS:

.balloon {
  /* set a default / starting value if JS doesn't supply anything */
  --size: 1;
  ...
  /* use var(...) to apply the value */
  transform: scale(var(--size));
}
Enter fullscreen mode Exit fullscreen mode

Heck, you can ditch that size variable entirely and make CSS the source of truth! Just read the value from CSS directly whenever you try to increment it:

pump.addEventListener('click', () => {
  // Note: you *can't* use balloon.style here!
  // This won't give you the up-to-date value of your variable.
  // For that, you'll need getComputedStyle(...)
    const size = getComputedStyle(balloon).getPropertyValue('--size');
  // size is a string at this stage, so we'll need to cast it to a number
  balloon.style.setProperty('--size', parseFloat(size) + 0.1)
})
Enter fullscreen mode Exit fullscreen mode

There's some caveats to this of course. Namely, CSS variables are always strings when you retrieve them, so you'll need to cast to an int or a float (for decimals) as necessary. The whole .style vs. getComputedStyle is a little weird to remember as well, so do whatever makes sense for you!

Here's a fully working example to pump up your confidence 🎈

✨ Let's get rolling on our shiny button

Before putting our newfound CSS variable knowledge to the test, let's jump into the styles we'll need for this button.

Remember that we want a smooth gradient of color to follow our mouse cursor, like a light shining on a piece of metal. As you can imagine, we'll want a radial-gradient on our button that we can easily move around.

We could add a gradient as a secondary background on our button (yes, you can overlay multiple backgrounds on the same element!). But for the sake of simplicity, let's just add another element inside our button representing our "shiny" effect. We'll do this using a pseudo-element to be fancy 😁

.shiny-button {
  /* add this property to our button, */
  /* so we can position our shiny gradient *relative* to the button itself */
  position: relative;
  /* then, make sure our shiny effect */
  /* doesn't "overflow" outside of our button */
  overflow: hidden;
  background: #3984ff; /* blue */
  ...
}

.shiny-button::after {
  /* all pseudo-elements need "content" to work. We'll make it empty here */
  content: '';
  position: absolute;
  width: 40px;
  height: 40px;
  /* make sure the gradient isn't too bright */
    opacity: 0.6;
  /* add a circular gradient that fades out on the edges */
    background: radial-gradient(white, #3984ff00 80%);
}
Enter fullscreen mode Exit fullscreen mode

Side note: You may have noticed our 8-digit hex code on the gradient background. This is a neat feature that lets you add transparency to your hex codes! More on that here.

Great! With this in place, we should see a subtle, stationary gradient covering our button.

🪤 Now, lets track some mouse cursors

We'll need to dig into some native browser APIs for this. You probably just listen for click 99% of the time, so it's easy to forget the dozens of other event listeners at our disposal! We'll need to use the mousemove event for our purposes:

const button = document.querySelector('.shiny-button')
button.addEventListener('mousemove', (e) => {
    ...
})
Enter fullscreen mode Exit fullscreen mode

If we log out or event object, we'll find some useful values in here. The main one's we're focusing on are clientX and clientY, which tell you the mouse position relative to the entire screen. Hover over this button to see what those values look like:

This is pretty useful, but it's not quite the info we're looking for. Remember that our shiny effect is positioned relative to the button surrounding it. For instance, to position the effect at the top-left corner of the button, we'd need to set top: 0; left: 0; So, we'd expect a reading of x: 0 y: 0 when we hover in our example above... But this definitely isn't the values that clientX and clientY give us 😕

There isn't a magical event property for this, so we'll need to get a little creative. Remember that clientX and clientY give us the cursor position relative to the window we're in. There's also this neat function called getBoundingClientRect(), which gets the x and y position of our button relative to the window. So if we subtract our button's position from our cursor's position... we should get our position relative to the button!

This is probably best explored with visuals. Hover your mouse around to see how our mouse values, boundingClientRect values, and subtracted values all interact:

💅 Pipe those coordinates into CSS

Alright, let's put two and two together here! We'll pass our values from the mousemove listener:

button.addEventListener("mousemove", (e) => {
  const { x, y } = button.getBoundingClientRect();
  button.style.setProperty("--x", e.clientX - x);
  button.style.setProperty("--y", e.clientY - y);
})
Enter fullscreen mode Exit fullscreen mode

Then, we'll add some CSS variables to that shiny pseudo-element from before:

.shiny-button::after {
  ...
  width: 100px;
  height: 100px;
  top: calc(var(--y, 0) * 1px - 50px);
  left: calc(var(--x, 0) * 1px - 50px);
}
Enter fullscreen mode Exit fullscreen mode

A couple notes here:

  1. We can set a default value for our variables using the second argument to var. In this case, we'll use 0 for both.

  2. CSS variables have a weird concept of "types." Here, we're assuming we'll pass our x and y as integers. This makes sense from our JavaScript, but CSS has a hard time figuring out that something like 10 really means 10px. To fix this, just multiply by the unit you want using calc (aka * 1px).

  3. We subtract half the width and height from our positioning. This ensures that our shiny circle is centered up with our cursor, instead of following with the top left corner.

Fade into our effect on entry

We're pretty much done here! Just one small tweak: if we leave this animation as-is, our shiny effect will always show in some corner of our button (even when we aren't hovering).

We could fix this from JavaScript to show and hide the effect. But why do that when CSS lets you style-on-hover already?

/* to explain this selector, we're */
/* selecting our ::after element when the .shiny-button is :hover-ed over */
.shiny-button:hover::after {
  /* show a faded shiny effect on hover */
  opacity: 0.4;
}
.shiny-button::after {
  ...
  opacity: 0;
  /* ease into view when "transitioning" to a non-zero opacity */
  transition: opacity 0.2s;
}
Enter fullscreen mode Exit fullscreen mode

Boom! Just add a one-line transition effect, and we get a nice fade-in. Here's our finished product ✨

🔨 Adapt to your framework of choice

I get it, you might be dismissing this article with all the eventListeners thinking well, I'm sure that JS looks much different in framework X. Luckily, the transition is pretty smooth!

First, you'll need to grab a reference to the button you're shine-ifying. In React, we can use a useRef hook to retrieve this:

const ShinyButton = () => {
  // null to start
  const buttonRef = React.useRef(null)
  React.useEffect(() => {
    // add a useEffect to check that our buttonRef has a value
    if (buttonRef) {
      ...
    }
  }, [buttonRef])

  return <button ref={buttonRef}>✨✨✨</button>
}
Enter fullscreen mode Exit fullscreen mode

Or in Svelte, we can bind our element to a variable:

<script>
  import { onMount } from 'svelte'
  let buttonRef
  // our ref always has a value onMount!
  onMount(() => {
    ...
  })
</script>

<button bind:this={buttonRef}>✨✨✨</button>
Enter fullscreen mode Exit fullscreen mode

Aside: I always like including Svelte examples, since they're usually easier to understand 😁

Once we have this reference, it's business-as-usual for our property setting:

React example

const ShinyButton = () => {
  const buttonRef = React.useRef(null)
  // throw your mousemove callback up here to "add" and "remove" later
  // might be worth a useCallback based on the containerRef as well!
  function mouseMoveEvent(e) {
    const { x, y } = containerRef.current.getBoundingClientRect();
    containerRef.current.style.setProperty('--x', e.clientX - x);
    containerRef.current.style.setProperty('--y', e.clientY - y);
  }

  React.useEffect(() => {
    if (buttonRef) {
      buttonRef.current.addEventListener('mousemove', mouseMoveEvent)
    }
    // don't forget to *remove* the eventListener
    // when your component unmounts!
    return () => buttonRef.current.removeEventListener('mousemove', mouseMoveEvent)
  }, [buttonRef])
  ...
Enter fullscreen mode Exit fullscreen mode

Svelte example

<script>
  import { onMount, onDestroy } from 'svelte'
  let buttonRef
  // again, declare your mousemove callback up top
  function mouseMoveEvent(e) {
    const { x, y } = buttonRef.getBoundingClientRect();
    buttonRef.style.setProperty('--x', e.clientX - x);
    buttonRef.style.setProperty('--y', e.clientY - y);
  }
  onMount(() => {
        buttonRef.addEventListener('mousemove', mouseMoveEvent)
  })
  onDestroy(() => {
    buttonRef.removeEventListener('mousemove', mouseMoveEvent)
  })
</script>
Enter fullscreen mode Exit fullscreen mode

The main takeaway: 💡 don't forget to remove event listeners when your component unmounts!

Check out our live example on Hack4Impact

If you want to see how this works in-context, check out this CodeSandbox for our Hack4Impact site. We also added some CSS fanciness to make this effect usable on any element, not just buttons ✨

To check out the component, head over here.

Learn a little something?

Awesome. In case you missed it, I launched an my "web wizardry" newsletter to explore more knowledge nuggets like this!

This thing tackles the "first principles" of web development. In other words, what are all the janky browser APIs, bent CSS rules, and semi-accessible HTML that make all our web projects tick? If you're looking to go beyond the framework, this one's for you dear web sorcerer 🔮

Subscribe away right here. I promise to always teach and never spam ❤️

. . . . .