Spotlight/Glowy Hover Effect using TypeScript, React, and TailwindCSS

Intro

Based on the following tweet:

We are going to implement the following component:

Pricing

Basic

$9.99

  • Access to standard workouts and nutrition plans
  • Email support
Get Started

Pro

$19.99

  • Access to advanced workouts and nutrition plans
  • Priority Email support
  • Exclusive access to live Q&A sessions
Upgrade to Pro

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
Go Ultimate

Basic

$9.99

  • Access to standard workouts and nutrition plans
  • Email support
Get Started

Pro

$19.99

  • Access to advanced workouts and nutrition plans
  • Priority Email support
  • Exclusive access to live Q&A sessions
Upgrade to Pro

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
Go Ultimate

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.

  1. Add the appropriate background colors
  2. Add the appropriate border colors
  3. 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.

  1. absolute and insert-0 will make sure that the copy is positioned in the center of the cards container
  2. select-none and pointer-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 cards
  • mask: `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 and pageY properties of the PointerEvent 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>
    </>
  );
};