A smooth 3D hover effect that brings interface elements to life with depth and motion.
import Tilt3D, { Tilt3DElement } from "@/registry/components/tilt-3d/tilt-3d";
const Tilt3DPreview = () => {
return (
<Tilt3D className="relative h-96 w-72 rounded-2xl">
<img
src="https://picsum.photos/id/296/320/480"
alt="Mountain"
className="absolute inset-0 h-full w-full object-cover rounded-[inherit]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent rounded-[inherit]" />
<Tilt3DElement z={30} className="absolute right-4 top-4">
<span className="rounded-full bg-white/20 px-3 py-1 text-xs font-medium text-white">
🏔️ Adventure
</span>
</Tilt3DElement>
<Tilt3DElement z={25} className="absolute bottom-0 left-0 right-0 p-6 text-white">
<p className="text-sm text-white/80">Switzerland</p>
<h3 className="mt-1 text-3xl font-bold">
Zermatt
</h3>
<p className="mt-2 text-sm leading-relaxed text-white/75">
Discover breathtaking alpine peaks, scenic trails, and unforgettable mountain views.
</p>
</Tilt3DElement>
</Tilt3D>
);
};
export default Tilt3DPreview; Install the following packages before using this component.
import { createContext, memo, useCallback, useContext, useMemo, useRef, useState, type ComponentProps, type ComponentType, type MouseEventHandler } from "react";
import { motion, useMotionValue, useSpring, useTransform, type HTMLMotionProps, type HTMLElements } from "motion/react";
import cn from "@/utils/cn";
export type Tilt3DElementProps<T extends keyof HTMLElements> = {
as?: keyof typeof motion<T>;
z?: number;
} & HTMLMotionProps<T>;
export type Tilt3DProps = {
tiltX?: number;
tiltY?: number;
wrapperProps?: HTMLMotionProps<"div">,
} & ComponentProps<"div">;
const Tilt3DContext = createContext({
isHovered: false,
});
export const Tilt3DElement = <T extends keyof HTMLElements,>(
props: Tilt3DElementProps<T>,
) => {
const {
children,
className,
style,
z = 0,
as = "div",
...restProps
} = props;
const { isHovered } = useContext(Tilt3DContext);
const Component = (motion[as]) as ComponentType<HTMLMotionProps<T>>;
return (
<Component
{...restProps as HTMLMotionProps<T>}
className={cn(
"[transform-style:preserve-3d]",
className,
)}
animate={{
z: isHovered ? z: 0,
}}
style={{
...style,
z: 0,
}}
transition={{
type: "spring",
stiffness: 200,
damping: 50,
mass: 0.5,
}}
>
{children}
</Component>
)
};
const Tilt3D = (
props: Tilt3DProps,
) => {
const {
children,
className,
tiltX = 25,
tiltY = 25,
wrapperProps = {},
...restProps
} = props;
const {
className: wrapperClassName,
style: wrapperStyle,
...restWrapperProps
} = wrapperProps;
const [isHovered, setIsHovered] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
const elementSize = useRef({
width: 0,
height: 0,
});
const springConfig = {
stiffness: 200,
damping: 50,
mass: 0.5,
};
const rotateX = useSpring(
useTransform(
mouseY,
[0, elementSize.current.height / 2, elementSize.current.height],
[tiltY, 0, -tiltY],
),
springConfig,
);
const rotateY = useSpring(
useTransform(
mouseX,
[0, elementSize.current.width / 2, elementSize.current.width],
[-tiltX, 0, tiltX],
),
springConfig,
);
const handleMouseMove: MouseEventHandler<HTMLDivElement> = useCallback((event) => {
if (!ref.current) return;
const {top, left, width, height} = ref.current.getBoundingClientRect();
mouseX.set(event.pageX - left);
mouseY.set(event.pageY - top);
elementSize.current.width = width;
elementSize.current.height = height;
}, [isHovered]);
const handleMouseEnter = useCallback(() => {
setIsHovered(true);
}, []);
const handleMouseLeave = useCallback(() => {
setIsHovered(false);
}, []);
const tilt3DContextValue = useMemo(() => ({
isHovered
}), [isHovered]);
if (!isHovered) {
rotateX.set(0);
rotateY.set(0);
}
return (
<Tilt3DContext.Provider value={tilt3DContextValue}>
<div
{...restProps}
className={cn(
"[perspective:800px]",
className,
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseMove={handleMouseMove}
ref={ref}
>
<motion.div
{...restWrapperProps}
className={cn(
"w-full h-full rounded-[inherit] [transform-style:preserve-3d]",
wrapperClassName,
)}
style={{
...wrapperStyle,
rotateX,
rotateY,
}}
>
{children}
</motion.div>
</div>
</Tilt3DContext.Provider>
);
};
export default memo(Tilt3D); | Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| children | React.ReactNode | Yes | – | Content rendered inside the 3D tilt container. |
| tiltX | number | No | 25 | Controls the horizontal (X-axis) tilt angle of the 3D effect. |
| tiltY | number | No | 25 | Controls the vertical (Y-axis) tilt angle of the 3D effect. |
| wrapperProps | React.HTMLAttributes<HTMLDivElement> | No | – | Props forwarded to the wrapper element, including attributes like className, style, id, and event handlers. |
| className | string | No | – | Additional CSS classes applied to the wrapper element. |
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| z | number | No | 0 | Z-axis translation value applied when the parent Tilt3D component is hovered. Higher values make the element appear elevated. |
| as | keyof typeof motion | No | "div" | Motion element type to render. Supports any HTML element available through Framer Motion. |
| className | string | No | – | Additional CSS classes applied to the element. |
If you find this component useful, consider starring the repository on GitHub. Found a bug or have a suggestion? Open an issue to help improve it.