Spotlight Button Animation Jun 22

Spotlight effect on button background that follows the mouse around while hovering.

CSSJavaScriptReactCode

About this component

This effect has become quite popular recently. The version above comes from Vercel, on their latest event page. Another good example is the CTA button on Harvey.AI, who have given it a little more of a neon effect.

The spotlight adds extra interest to a button, and makes the whole design feel more luxurious. It's done by using a radial gradient as the background, and then animating the gradient's location based on the mouse position.

How to build it

The basic principle of this animation is to set a radial-gradient() as the background of the button, and then animate the gradient's location based on the mouse position.

What we're building

Note: In the Vercel example, they're also animating the border of the button. I've chosen to leave that out of this explanation to keep it simple. I'll go into more detail about the border effect in a future post.

Three steps

To build the animation, and have it follow the mouse position, we'll have to do three things:

  • Add a radial-gradient() background to the button using the ::after pseudo-element
  • Add an event listener to track the mouse position on the button
  • Connect the mouse position state to the gradient location

Note: I'm only going to mention the CSS necessary for the animation itself. The rest of the styles can be found in the CodeSandBox at the end.

Step 1: Creating the radial gradient

We're starting with just one simple element, a <button>. To create the radial-gradient, we have to add some styles to the button itself, and to the ::after pseudo-element.

Let's start with the radial gradient. By using CSS variables instead of hard-coded values, we can easily update the location of the gradient later on.

CSS
button {
  --left-value: 0px;
  --top-value: 0px;
}
button::after {
  background: radial-gradient(
    circle at var(--left-value) var(--top-value),
    #b8b8b8,
    #0000000f
  );
}

To make sure the ::after element (and in turn, the gradient) covers the entire button, we need to add the code below.

CSS
button {
  --left-value: 0px;
  --top-value: 0px;
  position:relative;
}
button::after {
  background: radial-gradient(
    circle at var(--left-value) var(--top-value),
    #b8b8b8,
    #0000000f
  );
  position: absolute;
  inset: 0px;
  content: "";
}

By using inset:0 along with position absolute, the ::after pseudo-element will span from the left to the right, and from the top to the bottom of the button. Inset is a shorthand property for setting left, right, top, and bottom all at the same time.

Position relative is necessary to make sure the ::after element is positioned relative to the button itself, and not to the viewport.

Finally, we need to add content:"", because without any value for content, the ::after element won't show up at all.

The radial gradient so far

Tip: The reason we're using the ::after pseudo-element is mostly a matter of convenience, as it means we don't have to add an unnecessary empty div to the button. You could also use a div, and then add the gradient as a background to that div. Read more about ::after on CSS Tricks.

Step 2: Listening for the mouse position

Since we want the spotlight to follow the mouse around, we need to know where the mouse is located exactly. Because the radial gradient is positioned relative to the button, we're looking for the relative mouse position on the button itself, measured from the top left corner.

To calculate the relative mouse position on the button, we need to have the position of the mouse within the viewport, and the position of the button within the viewport.

In order to get those positions, we'll listen for the onMouseMove event on the button, which will enable us to get some data about the mouse position. This is updated every time you move the mouse on the button.

We'll pass this event to the getRelativeCoordinates function that will then calculate the relative mouse position on the button.

JS
const getRelativeCoordinates = (event) => {
  const rect = event.target.getBoundingClientRect();
  return {
    x: event.clientX - rect.left,
    y: event.clientY - rect.top
  };
}

To understand what this function does, let's break it down into its parts.

  • event.target.getBoundingClientRect() returns data about the size and position of the button.
    • 'Left' and 'top' are the horizontal and vertical positions of the button within the viewport.
  • event.clientX returns the horizontal position of the mouse within the viewport.
  • event.clientY returns the vertical position of the mouse within the viewport.

If you subtract the distance of the button from the edges of the viewport (rect.left) from the distance of the mouse from the edges of the viewport (event.clientX), you get the distance of the mouse from the edges of the button (x).

To make this more clear, I've added a visual example below. Try hovering to see the different distances in action.

x
0
=
e.clientX
0
-
rect.left
0
y
0
=
e.clientY
0
-
rect.top
0
Hover over the button to see how the mouse position is calculated

Note: I've rounded these values to integers for presentation purposes, but they can actually be many decimals long, so it's a little more fuzzy than it looks in this example.

Remember that where the mouse is on the button, is where we want the radial-gradient() to be centered.

JSX
const [mousePosition, setMousePosition] = useState({
  x: 0,
  y: 0,
})
const handleMouseMove = (event) => {
  setMousePosition(getRelativeCoordinates(event))
}

<button onMouseMove={handleMouseMove} />

Whenever the mouse is moved, the onMouseMove event fires, and updates the mousePosition state with the returns of the getRelativeCoordinates function.

JSX
const [mousePosition, setMousePosition] = useState({});
const handleMouseMove = (event) => {
  setMousePosition(getRelativeCoordinates(event));
};

And then updating CSS variables accordingly.

JSX
<button
  style={{
    "--left-value": `${mousePosition.x}px`,
    "--top-value": `${mousePosition.y}px`
  }}
  onMouseMove={handleMouseMove}
>
</button>

As you can see, the radial gradient is now following the mouse around. Moving the mouse changes the mousePosition state, which in turn updates the CSS variables, on which the radial-gradient() depends.

Animated radial gradient

Bonus: Adding a smooth transition

To make the spotlight fade in and out smoothly, we can add a subtle transition. First we have to know when hovering starts and ends, then we can set the opacity of the spotlight to fade in or out accordingly.

First, create a new state to keep track of whether the mouse is hovering over the button or not. Also add two state updater functions to handle the onMouseEnter and onMouseLeave events.

JSX
const [showSpotlight, setShowSpotlight] = useState(false);
const handleHoverStart = () => {
  setShowSpotlight(true);
};
const handleHoverEnd = () => {
  setShowSpotlight(false);
};

Then add these event listeners to the button, and use the showSpotlight state to set the opacity of the spotlight. Just like before, we're using a CSS Variable to connect the React and JavaScript changes with the CSS.

JSX
<button
  style={{
    "--after-opacity": showSpotlight ? 0.5 : 0.2
  }}
  onMouseEnter={handleHoverStart}
  onMouseLeave={handleHoverEnd}
/>

Finally, using the --after-opacity CSS variable, we can update the opacity of the spotlight in CSS. Add a transition to make it fade in and out smoothly, instead of just appearing and disappearing.

CSS
button::after {
  opacity: var(--after-opacity);
  transition: opacity 0.5s ease-out;
}

The final result with the smooth transition is a lot more subtle and elegant than the previous version.

Final result with a smooth transition

CodeSandBox

Check out the CodeSandBox below to see the full code for this spotlight effect.

Get the latest components in your inbox

Subscribe

Roundup of new components every two weeks