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 { cn } from "@/lib/utils";
export type ThreeDTiltCardProps = {  children: React.ReactNode;  className?: string;  /** 0–1 factor for max tilt. `0.4` ≈ 10° (intensity × 25). */  intensity?: number;  /** Kept for API compat; glare covers the old parallax role. */  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;}
function usePrefersReducedMotion() {  const [reduce, setReduce] = useState(false);  useEffect(() => {    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");    const sync = () => setReduce(mq.matches);    sync();    mq.addEventListener("change", sync);    return () => mq.removeEventListener("change", sync);  }, []);  return reduce;}
export function ThreeDTiltCard({  children,  className,  intensity = 0.4,  depth: _depth = 60,  border = true,}: ThreeDTiltCardProps) {  void _depth;  const reduce = usePrefersReducedMotion();  const finePointer = useFinePointer();  const tiltRef = useRef<HTMLDivElement>(null);  const cardRef = useRef<HTMLDivElement>(null);  const active = !reduce && finePointer;  const maxTilt = intensity * 25;
  const reset = useCallback(() => {    const tilt = tiltRef.current;    const card = cardRef.current;    if (!tilt || !card) return;    tilt.classList.remove("is-hover");    card.classList.remove("is-tilting");    card.style.setProperty("--tilt-rx", "0deg");    card.style.setProperty("--tilt-ry", "0deg");  }, []);
  const track = useCallback(    (e: React.PointerEvent<HTMLDivElement>) => {      if (!active) return;      const tilt = tiltRef.current;      const card = cardRef.current;      if (!tilt || !card) return;      const r = tilt.getBoundingClientRect();      if (r.width === 0 || r.height === 0) return;      const px = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));      const py = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));      tilt.classList.add("is-hover");      card.classList.add("is-tilting");      card.style.setProperty(        "--tilt-ry",        ((px - 0.5) * maxTilt).toFixed(2) + "deg",      );      card.style.setProperty(        "--tilt-rx",        ((0.5 - py) * maxTilt).toFixed(2) + "deg",      );      card.style.setProperty("--tilt-gx", (px * 100).toFixed(1) + "%");      card.style.setProperty("--tilt-gy", (py * 100).toFixed(1) + "%");    },    [active, maxTilt],  );
  const onPointerDown = useCallback(    (e: React.PointerEvent<HTMLDivElement>) => {      if (!active) return;      // Touch / pen: capture so drag keeps targeting even past the edge.      if (e.pointerType !== "mouse") {        try {          e.currentTarget.setPointerCapture(e.pointerId);        } catch {          /* ignore */        }      }    },    [active],  );
  const onPointerLeave = useCallback(    (e: React.PointerEvent<HTMLDivElement>) => {      if (e.pointerType === "mouse") reset();    },    [reset],  );
  useEffect(() => {    if (!active) reset();  }, [active, reset]);
  return (    <div      ref={tiltRef}      className={cn("t-tilt relative", className)}      onPointerDown={active ? onPointerDown : undefined}      onPointerMove={active ? track : undefined}      onPointerUp={active ? reset : undefined}      onPointerCancel={active ? reset : undefined}      onPointerLeave={active ? onPointerLeave : undefined}    >      <div        ref={cardRef}        className={cn(          "t-tilt-card relative h-full w-full rounded-md bg-background",          border && "border border-border",        )}      >        <div className="relative z-10 h-full w-full">{children}</div>        <div className="t-tilt-glare" aria-hidden />      </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.