A performant and customisable follow mouse component built with React compound components

"use client";
import { MouseFollowContent, MouseFollowItem } from "@/components/mouse-follow";
import { useState } from "react";
import { Volume2Icon, VolumeOffIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import Image from "next/image";
import tailwindLogo from "@/public/assets/badges/tailwind_logo.png";
const EMOJIS = [
"๐คก", "๐ค", "๐คจ", "๐ค", "๐คฏ", "๐ค ","๐น",
"๐", "๐บ", "๐ป", "๐ฝ", "๐พ", "๐ค", "๐",
"๐ฟ", "๐ฉ", "๐", "๐", "๐", "๐", "๐",
"๐", "๐", "๐", "๐", "๐", "๐", "๐"
];
const FollowMouseDemo: React.FC = () => {
const [isVolumeOn, setIsVolumeOn] = useState(false);
const [selectedEmoji, setSelectedEmoji] = useState(EMOJIS[Math.floor(Math.random() * EMOJIS.length)]);
const onMouseLeave = () => {
setSelectedEmoji(EMOJIS[Math.floor(Math.random() * EMOJIS.length)]);
}
return (
<div className="flex flex-col w-full items-center justify-around space-y-20">
<h2 className="mt-0 text-4xl font-bold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary to-accent-foreground">
Mouse Follow
</h2>
<div className="w-full gap-4 flex justify-center">
<MouseFollowContent asChild>
<div className="flex cursor-pointer flex-col w-60 gap-4 bg-gradient-to-br from-background to-muted-foreground/10 border border-muted-foreground/10 rounded-lg p-4">
<span className="text-base text-primary">
Projects
</span>
<span className="text-sm text-muted-foreground">
Hover this card to see the mouse follow effect
</span>
<MouseFollowItem offsetX={50} offsetY={30}>
<FollowMouseItemContent label="Discover" />
</MouseFollowItem>
</div>
</MouseFollowContent>
<MouseFollowContent asChild>
<div className="flex cursor-pointer flex-col w-60 gap-4 bg-gradient-to-br from-background to-muted-foreground/10 border border-muted-foreground/10 rounded-lg p-4">
<span className="text-base text-primary">
About
</span>
<span className="text-sm text-muted-foreground">
It works smoothly even with multiple items
</span>
<MouseFollowItem offsetX={50} offsetY={30}>
<FollowMouseItemContent label="See more" />
</MouseFollowItem>
</div>
</MouseFollowContent>
</div>
<h2 className="mt-0 text-4xl font-bold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary to-accent-foreground">
Custom elements
</h2>
<div className="w-full gap-4 flex justify-center">
<MouseFollowContent asChild>
<div className="flex cursor-pointer flex-col w-60 gap-4 bg-gradient-to-br from-background to-muted-foreground/10 border border-muted-foreground/10 rounded-lg p-4">
<span className="text-base text-primary">
Icons
</span>
<span className="text-sm text-muted-foreground">
You can actually place any element you want inside the MouseFollowItem
</span>
<MouseFollowItem offsetX={50} offsetY={30}>
<div className="flex items-center gap-2 relative overflow-hidden rounded-full size-10 p-2">
<Image src={tailwindLogo} alt="Tailwind CSS" fill className="object-contain !m-0" />
</div>
</MouseFollowItem>
</div>
</MouseFollowContent>
<MouseFollowContent asChild>
<div
onMouseLeave={onMouseLeave}
className="flex cursor-pointer flex-col w-60 gap-4 bg-gradient-to-br from-background to-muted-foreground/10 border border-muted-foreground/10 rounded-lg p-4">
<span className="text-base text-primary">
Dynamic
</span>
<span className="text-sm text-muted-foreground">
You can play with it to create funny effects like this (Enter and leave the card more than once)
</span>
<MouseFollowItem offsetX={25} offsetY={25}>
{selectedEmoji}
</MouseFollowItem>
</div>
</MouseFollowContent>
</div>
<h2 className="mt-0 mb-2 text-4xl font-bold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary to-accent-foreground">
Different positions
</h2>
<p className="text-sm text-muted-foreground text-center">
You can use the offsetX and offsetY props to position the item where you want.
</p>
<div className="w-full gap-4 flex justify-center">
<MouseFollowContent asChild>
<div onClick={() => setIsVolumeOn(!isVolumeOn)} className="flex cursor-none select-none flex-col w-60 gap-4 bg-gradient-to-br from-background to-muted-foreground/10 border border-muted-foreground/10 rounded-lg p-4">
<span className="text-base text-primary">
Center
</span>
<span className="text-sm text-muted-foreground">
This items is centered to the mouse cursor
</span>
<MouseFollowItem>
<CenterItemContent isVolumeOn={isVolumeOn} />
</MouseFollowItem>
</div>
</MouseFollowContent>
<MouseFollowContent asChild>
<div className="flex flex-col w-60 gap-4 bg-gradient-to-br from-background to-muted-foreground/10 border border-muted-foreground/10 rounded-lg p-4">
<div className="flex flex-col gap-2">
<span className="text-base text-primary">
Top left
</span>
</div>
<span className="text-sm text-muted-foreground"> This item is positioned at the top left of the mouse cursor</span>
<MouseFollowItem offsetX={-40} offsetY={-20}>
<FollowMouseItemContent label="Click me" />
</MouseFollowItem>
</div>
</MouseFollowContent>
</div>
</div>
);
}
const FollowMouseItemContent = ({ label }: { label: string }) => {
return (
<div className="rounded-lg text-xs bg-muted-foreground/10 backdrop-blur-sm border border-muted-foreground/10 py-1 px-2">
<div className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-accent-foreground">{label}</div>
</div>
);
};
const CenterItemContent = ({ isVolumeOn }: { isVolumeOn: boolean }) => {
return (
<div className={cn("rounded-full aspect-square text-xs p-6 border-2 border-dashed border-muted-foreground/20 transition-all duration-150", isVolumeOn && "border-primary/50 text-primary")}>
<Volume2Icon className={cn("size-4 text-primary transition-all duration-150 absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2", !isVolumeOn && "scale-0 opacity-0")} />
<VolumeOffIcon className={cn("size-4 text-primary transition-all duration-150 absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2", isVolumeOn && "scale-0 opacity-0")} />
</div>
);
};npm install @radix-ui/react-slotbun add @radix-ui/react-slotdeno add @radix-ui/react-slotpnpm add @radix-ui/react-slotyarn add @radix-ui/react-slotCopy and paste the following code into your project.
"use client";
import { cn } from "@/lib/utils";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
interface MouseContextType {
isVisible: boolean;
x: number;
y: number;
}
const MouseContext = React.createContext<MouseContextType | null>(null);
function MouseFollowContent({
asChild,
className,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const [mouseState, setMouseState] = React.useState<MouseContextType>({
isVisible: false,
x: 0,
y: 0,
});
const handleMouseEnter = React.useCallback(() => {
setMouseState((prev) => ({ ...prev, isVisible: true }));
}, []);
const handleMouseLeave = React.useCallback(() => {
setMouseState((prev) => ({ ...prev, isVisible: false }));
}, []);
const handleMouseMove = React.useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
setMouseState({
isVisible: true,
x: e.clientX,
y: e.clientY,
});
},
[]
);
const Comp = asChild ? Slot : "div";
return (
<MouseContext.Provider value={mouseState}>
<Comp
data-slot="mouse-follow-content"
className={className}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseMove={handleMouseMove}
{...props}
/>
</MouseContext.Provider>
);
}
MouseFollowContent.displayName = "MouseFollowContent";
interface MouseFollowItemProps extends React.ComponentProps<"div"> {
offsetX?: number;
offsetY?: number;
}
function MouseFollowItem({
offsetX = 0,
offsetY = 0,
className,
...props
}: MouseFollowItemProps) {
const context = React.useContext(MouseContext);
if (!context) {
console.error("MouseFollowItem must be used inside MouseFollowContent");
return null;
}
const { isVisible, x, y } = context;
return (
<div
data-slot="mouse-follow-item"
aria-hidden="true"
className={cn(
"pointer-events-none fixed z-[999]",
isVisible
? "scale-100 transition-transform duration-150"
: "scale-0 duration-0",
className
)}
style={{
left: x + offsetX,
top: y + offsetY,
transform: "translate(-50%, -50%)",
}}
{...props}
/>
)
}
MouseFollowItem.displayName = "MouseFollowItem";
export { MouseFollowContent, MouseFollowItem };import { MouseFollowContent, MouseFollowItem } from "@/components/mouse-follow";<MouseFollowContent asChild>
<div className="my-card-container">
<span>Card content</span>
<MouseFollowItem>
<MyCustomFollowCursorComponent>
</MouseFollowItem>
</div>
</MouseFollowContent><MouseFollowContent asChild>
<div className="my-card-container">
<span>Card content</span>
<MouseFollowItem offsetX={40} offsetY={30}>
<div>Follow cursor at the bottom right</div>
</MouseFollowItem>
</div>
</MouseFollowContent>You can also wrap your entire page in the MouseFollowContent to make it follow the mouse cursor everywhere.
export default function Layout({ children }) {
return (
<html>
<MouseFollowContent asChild>
<body className="flex flex-col min-h-svh">
{children}
<MouseFollowItem>
<span>Follow everywhere</span>
</MouseFollowItem>
</body>
</MouseFollowContent>
</html>
);
}When working with this comoponent, I faced an interesting use case. When trying to place a 3D model in the MouseFollowItem,
I noticed that, at page load, the model was not visibile, unless the user moved the mouse cursor before the page was fully loaded.
After some try and error, I found that the behaviour was related to the class scale-0 that was previously applied to the MouseFollowItem when the mouse cursor is not over its container.
I assume that having the scale set to 0, was breaking the 3D canvas size calculation.
So that, I switched to use the opacity instead of scaling, with opacity-0 instead of scale-0, and it worked.
That's a good example of how you can adapt the component to your needs.
| Prop | Type | Default Value | Description |
|---|---|---|---|
| asChild | boolean | false | Whether to render the component as a child |
| className? | string | - | Additional classes for the component |
| Prop | Type | Default Value | Description |
|---|---|---|---|
| offsetX? | number | 0 | The horizontal offset of the item |
| offsetY? | number | 0 | The vertical offset of the item |
| className? | string | - | Additional classes for the item |