Mouse Follow
A performant and customisable follow mouse component built with React compound components
Demo
Mouse Follow
Custom elements
Different positions
You can use the offsetX and offsetY props to position the item where you want.
"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>
);
};Installation
npm install @radix-ui/react-slotpnpm add @radix-ui/react-slotyarn add @radix-ui/react-slotbun 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 };Usage
import { MouseFollowContent, MouseFollowItem } from "@/components/mouse-follow";Basic
<MouseFollowContent asChild>
<div className="my-card-container">
<span>Card content</span>
<MouseFollowItem>
<MyCustomFollowCursorComponent>
</MouseFollowItem>
</div>
</MouseFollowContent>Custom position
<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>Global
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>
);
}Techbook
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 applied to the MouseFollowItem when the mouse cursor is not over its container.
I assume that having the scale set to 0, was somehow breaking the 3D canvas size calculation.
As a workaround, I switched to use the opacity instead of scaling, with opacity-0 instead of scale-0, and it worked.
So this is a good example of how you can adapt the component to your needs!
Props
MouseFollowContent
| Prop | Type | Default Value | Description |
|---|---|---|---|
| asChild | boolean | false | Whether to render the component as a child |
| className? | string | - | Additional classes for the component |
MouseFollowItem
| 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 |
