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!)
It's Astro Community Day! ๐ฅณ
— Astro (@astrodotbuild) April 7, 2022
You might be wondering...
Who are some ๐ฏ Astro contributors?
Where does Astro's sponsorship ๐ฐ go?
How does Astro give back to OSS?
Let's get into it! A thread ๐งต pic.twitter.com/PrCThabHYM
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!
๐ 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)';
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})`;
})
There's nothing wrong with this so far. But it has some growing pains:
- 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. - 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 thebackground-position
as the balloon inflates? Or theheight
andwidth
? Or somelinear-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);
})
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));
}
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)
})
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 ๐
See the Pen by Benjamin Holmes (@bholmesdev) on CodePen.
โจ 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%);
}
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.
See the Pen by Benjamin Holmes (@bholmesdev) on CodePen.
๐ชค 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) => {
...
})
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:
See the Pen by Benjamin Holmes (@bholmesdev) on CodePen.
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:
See the Pen by Benjamin Holmes (@bholmesdev) on CodePen.
๐ 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);
})
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);
}
A couple notes here:
We can set a default value for our variables using the second argument to
var
. In this case, we'll use 0 for both.CSS variables have a weird concept of "types." Here, we're assuming we'll pass our
x
andy
as integers. This makes sense from our JavaScript, but CSS has a hard time figuring out that something like10
really means10px
. To fix this, just multiply by the unit you want usingcalc
(aka* 1px
).We subtract half the
width
andheight
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;
}
Boom! Just add a one-line transition effect, and we get a nice fade-in. Here's our finished product โจ
See the Pen by Benjamin Holmes (@bholmesdev) on CodePen.
๐จ 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>
}
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>
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])
...
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>
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.
And if this helped you out, you can learn more about โค๏ธ Hack4Impact here.