Spotlight/Glowy Hover Effect using TypeScript, React, and TailwindCSS
Intro
Based on the following tweet:
We are going to implement the following component:
Pricing
Pro
$19.99
- Access to advanced workouts and nutrition plans
- Priority Email support
- Exclusive access to live Q&A sessions
Ultimate
$29.99
- Access to all premium workouts and nutrition plans
- 24/7 Priority support
- 1-on-1 virtual coaching session every month
- Exclusive content and early access to new features
Pro
$19.99
- Access to advanced workouts and nutrition plans
- Priority Email support
- Exclusive access to live Q&A sessions
Ultimate
$29.99
- Access to all premium workouts and nutrition plans
- 24/7 Priority support
- 1-on-1 virtual coaching session every month
- Exclusive content and early access to new features
Installation
Start by creating a new React project. I will be using Next.js for this tutorial. We will be needing to setup the project with tailwindCSS and TypeScript
npx create-next-app@latest spotlight-glowy --ts --tailwind
Implementation
Step 1
Create a new React component. Call it spotlight.tsx
const Spotlight = () => {
return <></>;
};
Add the content of the page:
const Spotlight = () => {
return (
<main className="p-14">
{/* main_heading */}
<h1 className="text-center text-white mb-5 text-2xl">Pricing</h1>
{/* cards container*/}
<div className="relative">
{/* main cards */}
<div className="flex flex-wrap gap-10">
<div className="flex-1 flex flex-col items-start p-10 gap-5 text-white border-[#eceff133] border border-solid rounded-xl transition-colors bg-[#212121] hover:bg-[#303030]">
<h2>Basic</h2>
<p>$9.99</p>
<ul className="list-disc">
<li>Access to standard workouts and nutrition plans</li>
<li>Email support</li>
</ul>
<a
href="#basic"
className="block bg-black rounded-lg p-3 w-full text-center font-semibold mt-auto"
>
Get Started
</a>
</div>
<div className="flex-1 flex flex-col items-start p-10 gap-5 text-white border-[#eceff133] border border-solid rounded-xl transition-colors bg-[#212121] hover:bg-[#303030]">
<h2>Pro</h2>
<p>$19.99</p>
<ul className="list-disc">
<li>Access to advanced workouts and nutrition plans</li>
<li>Priority Email support</li>
<li>Exclusive access to live Q&A sessions</li>
</ul>
<a
href="#pro"
className="block bg-black rounded-lg p-3 w-full text-center font-semibold mt-auto"
>
Upgrade to Pro
</a>
</div>
<div className="flex-1 flex flex-col items-start p-10 gap-5 text-white border-[#eceff133] border border-solid rounded-xl transition-colors bg-[#212121] hover:bg-[#303030]">
<h2>Ultimate</h2>
<p>$29.99</p>
<ul className="list-disc">
<li>Access to all premium workouts and nutrition plans</li>
<li>24/7 Priority support</li>
<li>1-on-1 virtual coaching session every month</li>
<li>Exclusive content and early access to new features</li>
</ul>
<a
href="#ultimate"
className="block bg-black rounded-lg p-3 w-full text-center font-semibold mt-auto"
>
Go Ultimate
</a>
</div>
</div>
</div>
</main>
);
};
The most important part of the component is the following:
- Cards container
- Main cards
- The cards
The cards container is the container that will hold the cards. We will use the relative
class to make sure that the cards are positioned relative to the container.
The main cards are the cards that will be displayed when the user is not hovering over the cards. We will use the flex-wrap
class to make sure that the cards are wrapped when the screen is not wide enough to display all the cards in one row.
You can have as many cards as you want. You can customize each card as you want. For example, you might want to give the cards different colors, different sizes, different fonts, etc. In this example, I've chosen to give all cards the same styling to keep things simple.
We wrap the component in some padding, you can omit this if you are going to use the component in some other container that handles the padding in the main
-tag. Freely omit the main
-tag if you are going to use the component in some other container.
Step 2
Copy everything "main cards" container and all of its child components. We'll call this "twin-cards". Paste it next to the main cards.
const Spotlight = () => {
return (
<main ...>
<h1 ...>
{/* cards container*/}
<div ...>
{/* main cards */}
<div ...>
{/* cards */}
...
</div>
{/* twin-cards */}
<div className="..." >
<div className="...">
...
</div>
<div className="...">
...
</div>
<div className="...">
...
</div>
</div>
</div>
</main>
);
};
Now we need to modify the styling of the twin-cards so that it looks like the way we want it to look when the user is hovering over the cards.
- Add the appropriate background colors
- Add the appropriate border colors
- Hide the text in the twin cards, as there is no need to display it twice
We are going to add a stronger color to the call-to-action button in the twin-cards compared to the rest of the card. This will make it stand out more.
const Spotlight = () => {
return (
<main ...>
<h1 ...>
{/* cards container*/}
<div ...>
{/* main cards */}
<div ...>
{/* cards */}
...
</div>
{/* twin-cards */}
<div className="..." >
<div className="... border-[#3cffce] border border-solid rounded-xl transition-colors bg-[#217661] text-transparent">
...
<a
href="#basic"
className="... bg-[#1de9b6]"
>
Get Started
</a>
</div>
<div className="... border-[#3cffce] border border-solid rounded-xl transition-colors bg-[#217661] text-transparent">
...
<a
href="#pro"
className="... bg-[#1de9b6]"
>
Upgrade to Pro
</a>
</div>
<div className="... border-[#3cffce] border border-solid rounded-xl transition-colors bg-[#217661] text-transparent">
...
<a
href="#ultimate"
className="... bg-[#1de9b6]"
>
Go Ultimate
</a>
</div>
</div>
</div>
</main>
);
};
Then wrap the twin-cards in a div with the following classes: select-none pointer-events-none absolute inset-0
.
absolute
andinsert-0
will make sure that the copy is positioned in the center of the cards containerselect-none
andpointer-events-none
will ensure that the user is not able to interact with the component
const Spotlight = () => {
return (
<main ...>
<h1 ...>
{/* cards container*/}
<div ...>
{/* main cards */}
<div ...>
{/* cards */}
...
</div>
{/* twin-cards */}
<div className="... select-none pointer-events-none absolute inset-0" >
<div className="...">
...
</div>
<div className="...">
...
</div>
<div className="...">
...
</div>
</div>
</div>
</main>
);
};
Step 3
Final step: Make the twin-cards appear when the user is hovering over the main cards using css variables.
We are going to add some styling to our twin-card container so that it will make the radial-effect appear when the user is hovering over the main cards. We are going to add the following:
opacity: "var(--opacity, 0)"
will make sure that the twin-cards are not visible when the user is not hovering over the main cardsmask: `radial-gradient(25rem 25rem at var(--x) var(--y), #000 1%, transparent 50%)`
will make sure that the twin-cards are visible when the user is hovering over the main cards. --x and --y are css variables that specifies where the center of the radial will be. We will set using javascript in part 3.WebkitMask
for compatibility
const Spotlight = () => {
return (
<main ...>
<h1 ...>
{/* cards container*/}
<div ...>
{/* main cards */}
<div ...>
{/* cards */}
...
</div>
{/* twin-cards */}
<div
className="..."
style={{
opacity: "var(--opacity, 0)",
mask: `
radial-gradient(
25rem 25rem at var(--x) var(--y),
#000 1%,
transparent 50%
)`,
WebkitMask: `
radial-gradient(
25rem 25rem at var(--x) var(--y),
#000 1%,
transparent 50%
)`,
}}
>
<div className="...">
...
</div>
<div className="...">
...
</div>
<div className="...">
...
</div>
</div>
</div>
</main>
);
};
Then finally, we need to react to the user's mouse movements.
Firstly, we need to hook into React's virtual DOM and get the reference to the cards container. We will use the useRef
hook for this.
import { useRef } from "react";
const Spotlight = () => {
const cardsContainer = useRef<HTMLDivElement>(null);
^^^^^^^^^^^^^^
return (
<main ...>
<h1 ...>
{/* cards container*/}
<div className="..." ref={cardsContainer}>
... ^^^^^^^^^^^^^
{/* main cards */}
<div ...>
{/* cards */}
...
</div>
{/* twin-cards */}
<div ...>
...
</div>
</div>
</main>
);
};
When the mouse hover over the main cards, we need to update the css variables --x
and --y
.
We will add an event listener to the body of the document that will listen for the pointermove
event. When the event is triggered, we will update the css variables.
--x
will be the x-coordinate of the mouse relative to the cards container--y
will be the y-coordinate of the mouse relative to the cards container- We can calculate the relative positioning using the
pageX
andpageY
properties of thePointerEvent
object --opacity
will be 1 when the user is hovering over the main cards, and 0 when the user is not hovering over the main cards- We will use the
useRef
hook to get a reference to the cards container
import { useEffect, useRef } from "react";
const Spotlight = () => {
const cardsContainer = useRef<HTMLDivElement>(null);
const applyOverlayMask = (e: PointerEvent) => {
const documentTarget = e.currentTarget as Element;
if (!cardsContainer.current) {
return;
}
// Calculate the x and y coordinates of the mouse relative to the cards container
const x = e.pageX - cardsContainer.current.offsetLeft;
const y = e.pageY - cardsContainer.current.offsetTop;
// Update the css variables
cardsContainer.current.setAttribute(
"style",
`--x: ${x}px; --y: ${y}px; --opacity: 1`
);
};
useEffect(() => {
// When the page renders, add the event listener
document.body.addEventListener("pointermove", (e) => {
applyOverlayMask(e);
});
// Clean up function
return () => {
document.body.removeEventListener("pointermove", (e) => {
applyOverlayMask(e);
});
};
}, []);
return (...);
};
Now you're done 🚀. Hover your mouse over your component and see the spotlight in action.
Full source code
import { useEffect, useRef } from "react";
export const Spotlight = () => {
const cardsContainer = useRef<HTMLDivElement>(null);
const applyOverlayMask = (e: PointerEvent) => {
const documentTarget = e.currentTarget as Element;
if (!cardsContainer.current) {
return;
}
const x = e.pageX - cardsContainer.current.offsetLeft;
const y = e.pageY - cardsContainer.current.offsetTop;
cardsContainer.current.setAttribute(
"style",
`--x: ${x}px; --y: ${y}px; --opacity: 1`
);
};
useEffect(() => {
document.body.addEventListener("pointermove", (e) => {
applyOverlayMask(e);
});
return () => {
document.body.removeEventListener("pointermove", (e) => {
applyOverlayMask(e);
});
};
}, []);
return (
<>
<main className="max-w-[1000px] p-14">
<h1 className="text-center mb-5 text-2xl">Pricing</h1>
<div className="relative" ref={cardsContainer}>
<div className="flex flex-wrap gap-10">
<div className="flex-1 flex flex-col items-start p-10 gap-5 text-white border-[#eceff133] border border-solid rounded-xl transition-colors bg-[#212121] hover:bg-[#303030]">
<h2>Basic</h2>
<p>$9.99</p>
<ul className="list-disc">
<li>Access to standard workouts and nutrition plans</li>
<li>Email support</li>
</ul>
<a
href="#basic"
className="block bg-black rounded-lg p-3 w-full text-center font-semibold mt-auto"
>
Get Started
</a>
</div>
<div className="flex-1 flex flex-col items-start p-10 gap-5 text-white border-[#eceff133] border border-solid rounded-xl transition-colors bg-[#212121] hover:bg-[#303030]">
<h2>Pro</h2>
<p>$19.99</p>
<ul className="list-disc">
<li>Access to advanced workouts and nutrition plans</li>
<li>Priority Email support</li>
<li>Exclusive access to live Q&A sessions</li>
</ul>
<a
href="#pro"
className="block bg-black rounded-lg p-3 w-full text-center font-semibold mt-auto"
>
Upgrade to Pro
</a>
</div>
<div className="flex-1 flex flex-col items-start p-10 gap-5 text-white border-[#eceff133] border border-solid rounded-xl transition-colors bg-[#212121] hover:bg-[#303030]">
<h2>Ultimate</h2>
<p>$29.99</p>
<ul className="list-disc">
<li>Access to all premium workouts and nutrition plans</li>
<li>24/7 Priority support</li>
<li>1-on-1 virtual coaching session every month</li>
<li>Exclusive content and early access to new features</li>
</ul>
<a
href="#ultimate"
className="block bg-black rounded-lg p-3 w-full text-center font-semibold mt-auto"
>
Go Ultimate
</a>
</div>
</div>
{/* twin cards */}
<div
className="flex flex-wrap gap-10 select-none pointer-events-none absolute inset-0"
style={{
opacity: "var(--opacity, 0)",
mask: `
radial-gradient(
25rem 25rem at var(--x) var(--y),
#000 1%,
transparent 50%
)`,
WebkitMask: `
radial-gradient(
25rem 25rem at var(--x) var(--y),
#000 1%,
transparent 50%
)`,
}}
>
{/* card */}
<div className="flex-1 flex flex-col items-start p-10 gap-5 text-white border-[#3cffce] border border-solid rounded-xl transition-colors bg-[#217661] text-transparent">
<h2>Basic</h2>
<p>$9.99</p>
<ul className="list-disc">
<li>Access to standard workouts and nutrition plans</li>
<li>Email support</li>
</ul>
<a
href="#basic"
className="block bg-[#1de9b6] rounded-lg p-3 w-full text-center font-semibold mt-auto"
>
Get Started
</a>
</div>
<div className="flex-1 flex flex-col items-start p-10 gap-5 text-white border-[#3cffce] border border-solid rounded-xl transition-colors bg-[#217661] text-transparent">
<h2>Pro</h2>
<p>$19.99</p>
<ul className="list-disc">
<li>Access to advanced workouts and nutrition plans</li>
<li>Priority Email support</li>
<li>Exclusive access to live Q&A sessions</li>
</ul>
<a
href="#pro"
className="block bg-[#1de9b6] rounded-lg p-3 w-full text-center font-semibold mt-auto"
>
Upgrade to Pro
</a>
</div>
<div className="flex-1 flex flex-col items-start p-10 gap-5 text-white border-[#3cffce] border border-solid rounded-xl transition-colors bg-[#217661] text-transparent">
<h2>Ultimate</h2>
<p>$29.99</p>
<ul className="list-disc">
<li>Access to all premium workouts and nutrition plans</li>
<li>24/7 Priority support</li>
<li>1-on-1 virtual coaching session every month</li>
<li>Exclusive content and early access to new features</li>
</ul>
<a
href="#ultimate"
className="block bg-[#1de9b6] rounded-lg p-3 w-full text-center font-semibold mt-auto"
>
Go Ultimate
</a>
</div>
</div>
</div>
</main>
</>
);
};