Tilting Card

This is a 3d card component built using nextjs

lucide-reactframer-motion

Tilted Project

Mouse-tracked 3D tilt with spotlight glow and shimmer border.

Hover to tilt

Installation

Install the required dependencies:

npm install lucide-react framer-motion
lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
components/ui/tilting-card.tsx
"use client";
import React, { useRef, useState } from "react";
import { motion, useMotionValue, useTransform, useSpring } from "framer-motion";
import { cn } from "@/lib/utils";

type CardProps = {
  title?: string;
  description?: string;
  badge?: string;
  motionOff?: boolean;
  className?: string;
  children?: React.ReactNode;
  glowIntensity?: "low" | "medium" | "high";
};

export function CardTilt({
  title,
  description,
  badge,
  motionOff = false,
  className,
  children,
  glowIntensity = "medium",
}: CardProps) {
  const cardRef = useRef<HTMLDivElement>(null);
  const [isHovered, setIsHovered] = useState(false);
  const [mousePos, setMousePos] = useState({ x: 0, y: 0 });

  const rawX = useMotionValue(0);
  const rawY = useMotionValue(0);

  const rotateX = useSpring(useTransform(rawY, [-1, 1], [14, -14]), {
    stiffness: 260,
    damping: 22,
  });
  const rotateY = useSpring(useTransform(rawX, [-1, 1], [-14, 14]), {
    stiffness: 260,
    damping: 22,
  });
  const rotateZ = useSpring(useTransform(rawX, [-1, 1], [-1.5, 1.5]), {
    stiffness: 200,
    damping: 30,
  });
  const scale = useSpring(isHovered && !motionOff ? 1.04 : 1, {
    stiffness: 300,
    damping: 25,
  });

  const glowOpacity = {
    low: 0.25,
    medium: 0.45,
    high: 0.65,
  }[glowIntensity];

  function handleMouseMove(e: React.MouseEvent<HTMLDivElement>) {
    if (motionOff || !cardRef.current) return;
    const rect = cardRef.current.getBoundingClientRect();
    const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
    const y = ((e.clientY - rect.top) / rect.height) * 2 - 1;
    rawX.set(x);
    rawY.set(y);
    setMousePos({
      x: ((e.clientX - rect.left) / rect.width) * 100,
      y: ((e.clientY - rect.top) / rect.height) * 100,
    });
  }

  function handleMouseLeave() {
    rawX.set(0);
    rawY.set(0);
    setIsHovered(false);
  }

  return (
    <motion.div
      ref={cardRef}
      style={
        !motionOff
          ? { rotateX, rotateY, rotateZ, scale, transformPerspective: 900 }
          : {}
      }
      onMouseMove={handleMouseMove}
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={handleMouseLeave}
      className={cn(
        "relative flex justify-center items-center cursor-pointer select-none",
        "rounded-3xl p-[10px] ring-5 ring-neutral-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] dark:shadow-[inset_0_2px_4px_rgba(255,255,255,0.3)]",
        " overflow-hidden",
        className,
      )}
    >
      <motion.div
        className={cn(
          "pointer-events-none absolute w-80 h-80 rounded-full -translate-x-1/2 blur-3xl -translate-y-1/2 -z-50",
          "bg-[radial-gradient(circle,_rgba(52,211,153,0.26)_0%,_rgba(16,185,129,0.16)_50%,_rgba(6,95,70,0.08)_100%)]",
          "dark:bg-[radial-gradient(circle,_rgba(52,211,153,0.15)_0%,_rgba(16,185,129,0.8)_50%,_rgba(6,95,70,0.04)_100%)]",
        )}
        animate={{ opacity: isHovered ? 1 : 0 }}
        transition={{ type: "spring", stiffness: 350, damping: 28 }}
        style={{
          left: `${mousePos.x}%`,
          top: `${mousePos.y}%`,
        }}
      />
      
      <div
        className={cn(
          "relative overflow-hidden w-full rounded-[22px] z-10",
          "bg-white dark:bg-zinc-900",
          "bg-zinc-100 dark:bg-zinc-800 shadow-[0_115px_5px_rgba(0,0,0,0.1),0_2px_5px_rgba(0,0,0,0.1)] dark:shadow-[0_115px_50px_rgba(255,255,255,0.2),0_5px_55px_rgba(255,255,255,0.2)]",
        )}
      >
        
        <div
          className="absolute inset-0 z-10 opacity-[0.035] dark:opacity-[0.06] pointer-events-none rounded-[22px]"
          style={{
            backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
            backgroundSize: "128px 128px",
          }}
        />

        <motion.div
          className="absolute top-0 left-8 right-8 h-px z-20"
          style={{
            background:
              "linear-gradient(to right, transparent, rgba(150,150,150,0.6), transparent)",
          }}
          animate={
            isHovered && !motionOff
              ? { opacity: [0.4, 1, 0.4], scaleX: [0.7, 1, 0.7] }
              : { opacity: 0.4, scaleX: 0.7 }
          }
          transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
        />

        <div className="relative z-30 p-7">
          {badge && (
            <motion.span
              initial={{ opacity: 0, y: -6 }}
              animate={{ opacity: 1, y: 0 }}
              className={cn(
                "inline-block mb-4 px-3 py-1 text-[10px] font-semibold tracking-[0.2em] uppercase rounded-full",
                "bg-zinc-100 text-zinc-500 border border-zinc-200",
                "dark:bg-zinc-800 dark:text-zinc-400 dark:border-zinc-700",
              )}
            >
              {badge}
            </motion.span>
          )}

          {children ? (
            <>{children}</>
          ) : (
            <>
              <motion.div
                className={cn(
                  "w-10 h-10 mb-5 rounded-xl flex items-center justify-center",
                  "bg-zinc-100 dark:bg-zinc-800",
                  "border border-zinc-200 dark:border-zinc-700",
                )}
                animate={
                  isHovered && !motionOff
                    ? { rotate: [0, -8, 8, 0] }
                    : { rotate: 0 }
                }
                transition={{ duration: 0.6, ease: "easeInOut" }}
              >
                <svg
                  width="18"
                  height="18"
                  viewBox="0 0 24 24"
                  fill="none"
                  className="text-zinc-500 dark:text-zinc-400"
                  stroke="currentColor"
                  strokeWidth="1.5"
                >
                  <rect x="3" y="3" width="7" height="7" rx="1" />
                  <rect x="14" y="3" width="7" height="7" rx="1" />
                  <rect x="3" y="14" width="7" height="7" rx="1" />
                  <rect x="14" y="14" width="7" height="7" rx="1" />
                </svg>
              </motion.div>

              <h3
                className={cn(
                  "text-[1.2rem] font-bold tracking-tight mb-2 leading-snug",
                  "text-zinc-900 dark:text-zinc-50",
                )}
              >
                {title || "Tilted Project"}
              </h3>
              <p
                className={cn(
                  "text-sm leading-relaxed",
                  "text-zinc-500 dark:text-zinc-400",
                )}
              >
                {description ||
                  "Mouse-tracked 3D tilt with spotlight glow and shimmer border."}
              </p>

              <div
                className={cn(
                  "mt-6 pt-4 flex items-center justify-between",
                  "border-t border-zinc-100 dark:border-zinc-800",
                )}
              >
                <span className="text-[11px] tracking-widest uppercase text-zinc-400 dark:text-zinc-600 font-medium">
                  Hover to tilt
                </span>
                <motion.div
                  className={cn(
                    "w-7 h-7 rounded-full flex items-center justify-center",
                    "bg-zinc-900 dark:bg-zinc-100",
                  )}
                  animate={
                    isHovered && !motionOff
                      ? { scale: 1.18, rotate: 45 }
                      : { scale: 1, rotate: 0 }
                  }
                  transition={{ type: "spring", stiffness: 300, damping: 18 }}
                >
                  <svg
                    width="12"
                    height="12"
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="2.5"
                    className="text-white dark:text-zinc-900"
                  >
                    <path d="M5 12h14M12 5l7 7-7 7" />
                  </svg>
                </motion.div>
              </div>
            </>
          )}
        </div>

        <motion.div
          className="absolute bottom-0 left-1/2 -translate-x-1/2 h-px w-3/4 pointer-events-none z-20"
          style={{
            background:
              "linear-gradient(to right, transparent, rgba(120,120,120,0.5), transparent)",
          }}
          animate={
            isHovered && !motionOff
              ? { opacity: 1, scaleX: 1 }
              : { opacity: 0, scaleX: 0.4 }
          }
          transition={{ duration: 0.4 }}
        />
      </div>
    </motion.div>
  );
}