Overview

Perspective tilt that follows the cursor. Drop in any content block — profile, feature, or callout.


Installation

Use the CLI to install the component automatically:

$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/3d-card.json

Install dependencies:

$ pnpm add motion

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/3d-card.tsx
tsx
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";import {  motion,  useMotionValue,  useSpring,  useTransform,  useReducedMotion,} from "motion/react";import { cn } from "@/lib/utils";
export type ThreeDTiltCardProps = {  children: React.ReactNode;  className?: string;  /** 0–1 factor for max tilt. `0.4` ≈ 10°. */  intensity?: number;  /** Pop-out distance (px) for inner content parallax. */  depth?: number;  border?: boolean;};
function useFinePointer() {  const [fine, setFine] = useState(false);  useEffect(() => {    const mq = window.matchMedia("(hover: hover) and (pointer: fine)");    const sync = () => setFine(mq.matches);    sync();    mq.addEventListener("change", sync);    return () => mq.removeEventListener("change", sync);  }, []);  return fine;}
const SPRING = { stiffness: 260, damping: 28, mass: 0.45 };
export function ThreeDTiltCard({  children,  className,  intensity = 0.4,  depth = 60,  border = true,}: ThreeDTiltCardProps) {  const reduce = useReducedMotion();  const finePointer = useFinePointer();  const frameRef = useRef<HTMLDivElement>(null);  const rectRef = useRef<DOMRect | null>(null);  const [hovering, setHovering] = useState(false);  const active = !reduce && finePointer;
  const x = useMotionValue(0);  const y = useMotionValue(0);  const z = useMotionValue(0);
  const springX = useSpring(x, SPRING);  const springY = useSpring(y, SPRING);  const springZ = useSpring(z, SPRING);
  const maxTilt = intensity * 25;
  // Compose a single transform string so the GPU owns the layer  // (Motion's rotateX/rotateY shorthands stay on the main thread).  const transform = useTransform([springX, springY], ([latestX, latestY]) => {    const rx = -(latestY as number) * maxTilt;    const ry = (latestX as number) * maxTilt;    return `rotateX(${rx}deg) rotateY(${ry}deg)`;  });
  const childTransform = useTransform(    [springX, springY, springZ],    ([latestX, latestY, latestZ]) => {      const px = -(latestX as number) * depth * 0.16;      const py = -(latestY as number) * depth * 0.16;      return `translate3d(${px}px, ${py}px, ${latestZ as number}px)`;    },  );
  const refreshRect = useCallback(() => {    rectRef.current = frameRef.current?.getBoundingClientRect() ?? null;  }, []);
  const resetTilt = useCallback(() => {    x.set(0);    y.set(0);    z.set(0);    rectRef.current = null;    setHovering(false);  }, [x, y, z]);
  const handlePointerMove = useCallback(    (e: React.PointerEvent<HTMLDivElement>) => {      if (!active || e.pointerType !== "mouse") return;
      // Fresh rect each move — scroll/layout can invalidate a cached box      refreshRect();      const rect = rectRef.current;      if (!rect || rect.width === 0 || rect.height === 0) return;
      // Normalize across the full card surface → [-1, 1]      const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1;      const ny = ((e.clientY - rect.top) / rect.height) * 2 - 1;
      x.set(Math.max(-1, Math.min(1, nx)));      y.set(Math.max(-1, Math.min(1, ny)));      z.set(depth);    },    [active, depth, refreshRect, x, y, z],  );
  const handlePointerEnter = useCallback(    (e: React.PointerEvent<HTMLDivElement>) => {      if (!active || e.pointerType !== "mouse") return;      setHovering(true);      refreshRect();    },    [active, refreshRect],  );
  useEffect(() => {    if (!active) resetTilt();  }, [active, resetTilt]);
  return (    <div      ref={frameRef}      onPointerEnter={handlePointerEnter}      onPointerMove={handlePointerMove}      onPointerLeave={resetTilt}      className={cn(        "relative perspective-distant",        hovering && "will-change-transform",        className,      )}    >      <motion.div        style={{          transform: active ? transform : undefined,          transformStyle: "preserve-3d",        }}        className={cn(          "relative h-full w-full rounded-md bg-background",          border && "border border-border",        )}      >        <div          style={{ transformStyle: "preserve-3d" }}          className="relative z-10 h-full w-full"        >          <motion.div            style={{              transform: active ? childTransform : undefined,              transformStyle: "preserve-3d",            }}          >            {children}          </motion.div>        </div>      </motion.div>    </div>  );}

Adjust import paths according to your folder structure.


Props

PropTypeRequiredDefaultDescription
childrenReact.ReactNodeYesContent to be rendered inside the 3D card.
classNamestringNoOptional custom class names for styling.
intensitynumberNo0.40–1 tilt factor across the card surface. 0.4 ≈ 10° at the edges.
depthnumberNo60Pop-out distance (px) for inner content parallax.
borderbooleanNotrueToggles the border around the card.

Example


Accessibility

The 3D Card follows accessibility best practices:

  • Reduced Motion — Animations respect prefers-reduced-motion.

  • Keyboard Friendly — Fully accessible with tab navigation.

  • Screen Reader Support — Uses semantic HTML for inner content.

  • Focus Indicators — Maintains visible focus states even with transforms applied.


Performance Tips

Use Hardware Acceleration — CSS transforms automatically offload to the GPU.

  • Limit Depth — Too much z-distance can trigger motion sickness.

  • Optimize Renders — Memoize content inside the card to avoid re-renders.

  • Touch Devices — Consider disabling tilt on mobile or use gentle touch parallax.