Fazekit

Code

"use client";
import * as React from "react";
import {
  motion,
  useMotionTemplate,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
  type MotionValue,
} from "motion/react";
import { cn } from "@/lib/utils";

export interface TiltCardProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Maximum rotation in degrees on each axis. */
  maxTilt?: number;
  /** Scale while the pointer is over the card. */
  hoverScale?: number;
  /** CSS perspective distance (px). Lower = more dramatic. */
  perspective?: number;
  /** Show the moving glare highlight. */
  glare?: boolean;
  /** Peak opacity of the glare (0–1). */
  glareOpacity?: number;
  /** Class applied to the inner 3D surface (rounded corners, background…). */
  surfaceClassName?: string;
}

const TiltContext = React.createContext<{ active: MotionValue<number> } | null>(null);

const SPRING = { stiffness: 180, damping: 18, mass: 0.5 };

export function TiltCard({
  maxTilt = 12,
  hoverScale = 1.03,
  perspective = 900,
  glare = true,
  glareOpacity = 0.35,
  className,
  surfaceClassName,
  children,
  ...props
}: TiltCardProps) {
  const reduce = useReducedMotion();
  const ref = React.useRef<HTMLDivElement>(null);

  // Pointer position within the card, 0..1 (0.5 = centre).
  const px = useMotionValue(0.5);
  const py = useMotionValue(0.5);
  const active = useMotionValue(0);

  const rotateX = useSpring(useTransform(py, [0, 1], [maxTilt, -maxTilt]), SPRING);
  const rotateY = useSpring(useTransform(px, [0, 1], [-maxTilt, maxTilt]), SPRING);
  const scale = useSpring(useTransform(active, [0, 1], [1, hoverScale]), SPRING);
  const glareAlpha = useSpring(useTransform(active, [0, 1], [0, glareOpacity]), SPRING);
  const gx = useTransform(px, (v) => `${v * 100}%`);
  const gy = useTransform(py, (v) => `${v * 100}%`);
  const glareBg = useMotionTemplate`radial-gradient(circle at ${gx} ${gy}, rgb(255 255 255 / 0.9), rgb(255 255 255 / 0) 55%)`;

  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    // Touch & pen: no tilt — scrolling stays smooth and nothing gets "stuck" tilted.
    if (reduce || e.pointerType !== "mouse" || !ref.current) return;
    const r = ref.current.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)));
    active.set(1);
  };

  const reset = () => {
    px.set(0.5);
    py.set(0.5);
    active.set(0);
  };

  return (
    <TiltContext.Provider value={{ active }}>
      <div
        ref={ref}
        onPointerMove={onPointerMove}
        onPointerLeave={reset}
        className={cn("relative", className)}
        style={{ perspective }}
        {...props}
      >
        <motion.div
          style={{ rotateX, rotateY, scale, transformStyle: "preserve-3d" }}
          className={cn("relative h-full w-full rounded-2xl will-change-transform", surfaceClassName)}
        >
          {children}
          {glare && (
            <motion.div
              aria-hidden
              className="pointer-events-none absolute inset-0 rounded-[inherit] mix-blend-overlay"
              style={{ background: glareBg, opacity: glareAlpha, transform: "translateZ(1px)" }}
            />
          )}
        </motion.div>
      </div>
    </TiltContext.Provider>
  );
}

export interface TiltCardLayerProps {
  /** How far (px) this layer floats towards the viewer while the card is tilted. */
  depth?: number;
  className?: string;
  style?: React.CSSProperties;
  children?: React.ReactNode;
}

/**
 * A child of `TiltCard` that pops forward in 3D while the card is hovered.
 * Every wrapper between the card and a layer needs `transform-style: preserve-3d`
 * (Tailwind: `[transform-style:preserve-3d]`), or nest layers directly.
 */
export function TiltCardLayer({ depth = 40, className, children, style }: TiltCardLayerProps) {
  const ctx = React.useContext(TiltContext);
  const fallback = useMotionValue(0);
  const z = useSpring(useTransform(ctx?.active ?? fallback, [0, 1], [0, depth]), SPRING);
  return (
    <motion.div className={cn("relative", className)} style={{ ...style, z, transformStyle: "preserve-3d" }}>
      {children}
    </motion.div>
  );
}

More in Cards

View all →