Tilted Card
3D perspective tilt card reacting to pointer.
pnpm dlx shadcn@latest add @leo/tilted-cardProps
| Name | Type | Default | Description |
|---|---|---|---|
| altText | string | undefined | Tilted card image | |
| captionText | string | undefined | ||
| className | string | undefined | - | |
| containerHeight | Height<string | number> | undefined | 300px | |
| containerWidth | Width<string | number> | undefined | 100% | |
| displayOverlayContent | boolean | undefined | false | |
| imageHeight | Height<string | number> | undefined | 300px | |
| imageSrc* | string | undefined | - | |
| imageWidth | Width<string | number> | undefined | 300px | |
| overlayContent | ReactNode | - | |
| rotateAmplitude | number | undefined | 14 | |
| scaleOnHover | number | undefined | 1.1 | |
| showMobileWarning | boolean | undefined | true | |
| showTooltip | boolean | undefined | true |
Dependencies
- motion@^12.23.12
Source
"use client";
/*
* Ported from react-bits (MIT, © David Haz): src/ts-tailwind/Components/TiltedCard/TiltedCard.tsx @ 9d0270d
*
* MIT License
*
* Copyright (c) 2026 David Haz
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import type { SpringOptions } from "motion/react";
import { motion, useMotionValue, useSpring } from "motion/react";
import type React from "react";
import { useRef, useState } from "react";
import { cn } from "@/lib/utils";
export interface TiltedCardProps {
className?: string;
imageSrc: React.ComponentProps<"img">["src"];
altText?: string;
captionText?: string;
containerHeight?: React.CSSProperties["height"];
containerWidth?: React.CSSProperties["width"];
imageHeight?: React.CSSProperties["height"];
imageWidth?: React.CSSProperties["width"];
scaleOnHover?: number;
rotateAmplitude?: number;
showMobileWarning?: boolean;
showTooltip?: boolean;
overlayContent?: React.ReactNode;
displayOverlayContent?: boolean;
}
const springValues: SpringOptions = {
damping: 30,
stiffness: 100,
mass: 2,
};
export function TiltedCard({
imageSrc,
altText = "Tilted card image",
captionText = "",
containerHeight = "300px",
containerWidth = "100%",
imageHeight = "300px",
imageWidth = "300px",
scaleOnHover = 1.1,
rotateAmplitude = 14,
showMobileWarning = true,
showTooltip = true,
overlayContent = null,
displayOverlayContent = false,
className,
}: TiltedCardProps) {
const ref = useRef<HTMLElement>(null);
const x = useMotionValue(0);
const y = useMotionValue(0);
const rotateX = useSpring(useMotionValue(0), springValues);
const rotateY = useSpring(useMotionValue(0), springValues);
const scale = useSpring(1, springValues);
const opacity = useSpring(0);
const rotateFigcaption = useSpring(0, {
stiffness: 350,
damping: 30,
mass: 1,
});
const [lastY, setLastY] = useState(0);
function handleMouse(e: React.MouseEvent<HTMLElement>) {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const offsetX = e.clientX - rect.left - rect.width / 2;
const offsetY = e.clientY - rect.top - rect.height / 2;
const rotationX = (offsetY / (rect.height / 2)) * -rotateAmplitude;
const rotationY = (offsetX / (rect.width / 2)) * rotateAmplitude;
rotateX.set(rotationX);
rotateY.set(rotationY);
x.set(e.clientX - rect.left);
y.set(e.clientY - rect.top);
const velocityY = offsetY - lastY;
rotateFigcaption.set(-velocityY * 0.6);
setLastY(offsetY);
}
function handleMouseEnter() {
scale.set(scaleOnHover);
opacity.set(1);
}
function handleMouseLeave() {
opacity.set(0);
scale.set(1);
rotateX.set(0);
rotateY.set(0);
rotateFigcaption.set(0);
}
return (
<figure
ref={ref}
className={cn(
"relative w-full h-full [perspective:800px] flex flex-col items-center justify-center",
className,
)}
style={{
height: containerHeight,
width: containerWidth,
}}
onMouseMove={handleMouse}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{showMobileWarning && (
<div className="absolute top-4 text-center text-sm block sm:hidden">
This effect is not optimized for mobile. Check on desktop.
</div>
)}
<motion.div
className="relative [transform-style:preserve-3d]"
style={{
width: imageWidth,
height: imageHeight,
rotateX,
rotateY,
scale,
}}
>
<motion.img
src={imageSrc}
alt={altText}
className="absolute top-0 left-0 object-cover rounded-[15px] will-change-transform [transform:translateZ(0)]"
style={{
width: imageWidth,
height: imageHeight,
}}
/>
{displayOverlayContent && overlayContent && (
<motion.div className="absolute top-0 left-0 z-[2] will-change-transform [transform:translateZ(30px)]">
{overlayContent}
</motion.div>
)}
</motion.div>
{showTooltip && (
<motion.figcaption
className="pointer-events-none absolute left-0 top-0 rounded-[4px] bg-white px-[10px] py-[4px] text-[10px] text-[#2d2d2d] opacity-0 z-[3] hidden sm:block"
style={{
x,
y,
opacity,
rotate: rotateFigcaption,
}}
>
{captionText}
</motion.figcaption>
)}
</figure>
);
}
export default TiltedCard;