Overview

A card that leans toward the pointer on springs, so it feels weighted rather than glued to the cursor. It lifts slightly while hovered, its shadow falls away from the raised edge, a soft light follows the pointer across the surface, and the border brightens nearest the pointer. Wrap parts of the content in TiltLayer and they float above the surface for a parallax effect. Touch screens and reduced motion get a flat card.


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, { createContext, useContext, useEffect, useState } from "react";import {  motion,  useMotionTemplate,  useMotionValue,  useReducedMotion,  useSpring,  useTransform,  type MotionValue,} 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;  /** Default depth in px for `TiltLayer`s that don't set their own. */  depth?: number;  border?: boolean;  /** Soft light that follows the pointer across the surface. */  glare?: boolean;  /** Scale up slightly while hovered. */  lift?: boolean;};
type TiltContext = { depth: number; active: MotionValue<number>; reduce: boolean };const Ctx = createContext<TiltContext | null>(null);
const SPRING = { stiffness: 260, damping: 22, mass: 0.6 };
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;}
export function ThreeDTiltCard({  children,  className,  intensity = 0.4,  depth = 40,  border = true,  glare = true,  lift = true,}: ThreeDTiltCardProps) {  const reduce = !!useReducedMotion();  const fine = useFinePointer();  const enabled = fine && !reduce;  const maxTilt = intensity * 25;
  // Pointer position over the card, 0–1 on each axis (0.5 = centre).  const px = useMotionValue(0.5);  const py = useMotionValue(0.5);  const hover = useMotionValue(0);  const sx = useSpring(px, SPRING);  const sy = useSpring(py, SPRING);  const active = useSpring(hover, { stiffness: 300, damping: 30 });
  const rotateY = useTransform(sx, [0, 1], [-maxTilt, maxTilt]);  const rotateX = useTransform(sy, [0, 1], [maxTilt, -maxTilt]);  const scale = useTransform(active, [0, 1], [1, lift ? 1.025 : 1]);
  const gx = useTransform(sx, (v) => `${v * 100}%`);  const gy = useTransform(sy, (v) => `${v * 100}%`);  const glareBg = useMotionTemplate`radial-gradient(circle at ${gx} ${gy}, rgba(255,255,255,0.32), rgba(255,255,255,0) 55%)`;  const edgeBg = useMotionTemplate`radial-gradient(240px circle at ${gx} ${gy}, color-mix(in oklab, var(--color-ink) 35%, transparent), transparent 70%)`;  const glareOpacity = useTransform(active, [0, 1], [0, 1]);
  // The shadow falls away from the raised edge.  const shadowX = useTransform(sx, [0, 1], [14, -14]);  const shadowY = useTransform(sy, [0, 1], [14, -6]);  const shadowBlur = useTransform(active, [0, 1], [18, 42]);  const shadowAlpha = useTransform(active, [0, 1], [0.1, 0.24]);  const boxShadow = useMotionTemplate`${shadowX}px ${shadowY}px ${shadowBlur}px -12px rgba(0,0,0,${shadowAlpha})`;
  const move = (e: React.PointerEvent<HTMLDivElement>) => {    if (!enabled || e.pointerType !== "mouse") return;    const r = e.currentTarget.getBoundingClientRect();    px.set(Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)));    py.set(Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)));    hover.set(1);  };  const leave = () => {    px.set(0.5);    py.set(0.5);    hover.set(0);  };
  return (    <Ctx.Provider value={{ depth, active, reduce: !enabled }}>      <div        className={cn("relative", className)}        style={{ perspective: 900 }}        onPointerMove={move}        onPointerLeave={leave}      >        <motion.div          className={cn(            "group/tilt relative h-full w-full rounded-[18px] bg-(--color-paper)",            border && "shadow-[inset_0_0_0_1px_var(--color-rule)]",          )}          style={            enabled              ? { rotateX, rotateY, scale, boxShadow, transformStyle: "preserve-3d" }              : { boxShadow: "0 12px 24px -16px rgba(0,0,0,0.2)" }          }        >          {/* Edge light: a ring that brightens nearest the pointer. */}          {enabled && border ? (            <motion.div              aria-hidden              className="pointer-events-none absolute -inset-px rounded-[19px]"              style={{                background: edgeBg,                opacity: glareOpacity,                padding: 1,                WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",                WebkitMaskComposite: "xor",                maskComposite: "exclude",              }}            />          ) : null}          <div className="relative h-full w-full" style={{ transformStyle: "preserve-3d" }}>            {children}          </div>          {enabled && glare ? (            <motion.div              aria-hidden              className="pointer-events-none absolute inset-0 overflow-hidden rounded-[18px] mix-blend-soft-light"              style={{ background: glareBg, opacity: glareOpacity, transform: "translateZ(1px)" }}            />          ) : null}        </motion.div>      </div>    </Ctx.Provider>  );}
/** * Lifts its children off the card surface while the card is tilted, for a * parallax effect. Use inside `ThreeDTiltCard`. */export function TiltLayer({  children,  depth,  className,}: {  children: React.ReactNode;  /** Pixels toward the viewer at full hover. */  depth?: number;  className?: string;}) {  const ctx = useContext(Ctx);  const fallback = useMotionValue(0);  const amount = depth ?? ctx?.depth ?? 40;  const z = useTransform(ctx?.active ?? fallback, [0, 1], [0, amount]);  if (!ctx || ctx.reduce) return <div className={className}>{children}</div>;  return (    <motion.div className={className} style={{ z, transformStyle: "preserve-3d" }}>      {children}    </motion.div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { ThreeDTiltCard, TiltLayer } from "@/components/ui/3d-card";
<ThreeDTiltCard intensity={0.4}>  <div className="p-5 [transform-style:preserve-3d]">    <TiltLayer depth={20}>      <span className="text-xs uppercase">Surface</span>    </TiltLayer>    <TiltLayer depth={36}>      <h3>Any content block</h3>    </TiltLayer>    <p>Flat text stays on the card.</p>  </div></ThreeDTiltCard>

Props

PropTypeRequiredDefaultDescription
childrenReactNodeYes—Card content.
intensitynumberNo0.40–1 strength of the tilt. 0.4 is about 10° at the edges.
depthnumberNo40Default lift in px for TiltLayers that don't set their own.
glarebooleanNotrueSoft light that follows the pointer.
liftbooleanNotrueScale up slightly while hovered.
borderbooleanNotrueHairline border, plus the edge light on hover.
TiltLayer depthnumberNo—Pixels a layer lifts toward the viewer at full hover.

Best Practices

  1. Any element between the card and a TiltLayer needs transform-style: preserve-3d and no overflow: hidden; either one flattens the layers.
  2. Round the corners of your own content (the card uses 18px) rather than clipping it.
  3. Keep the tilt subtle (0.3–0.5) for cards with text; go higher for visual pieces like passes or covers.
  4. The tilt only runs for a mouse or trackpad; touch and reduced motion get a still card with a soft shadow.