Web InnoventixFreeCode

Card Tilt Hover Effect

Original · free

A card that tilts in 3D toward the cursor on hover.

byWeb InnoventixReact + Tailwind
hovercardtilt
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/hover-card-tilt.json
hover-card-tilt.tsx
"use client";

import { type PointerEvent, useCallback, useRef, useState } from "react";

type Tilt = { rx: number; ry: number; gx: number; gy: number };

const REST: Tilt = { rx: 0, ry: 0, gx: 50, gy: 50 };
const MAX_DEG = 12;

export default function HoverCardTilt() {
  const cardRef = useRef<HTMLDivElement>(null);
  const rafRef = useRef<number | null>(null);
  const [tilt, setTilt] = useState<Tilt>(REST);
  const [active, setActive] = useState(false);

  const reduced =
    typeof window !== "undefined" &&
    window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;

  const compute = useCallback(
    (clientX: number, clientY: number) => {
      const el = cardRef.current;
      if (!el) return;
      const rect = el.getBoundingClientRect();
      const px = (clientX - rect.left) / rect.width;
      const py = (clientY - rect.top) / rect.height;
      const clampedX = Math.min(Math.max(px, 0), 1);
      const clampedY = Math.min(Math.max(py, 0), 1);
      setTilt({
        rx: (0.5 - clampedY) * MAX_DEG * 2,
        ry: (clampedX - 0.5) * MAX_DEG * 2,
        gx: clampedX * 100,
        gy: clampedY * 100,
      });
    },
    [],
  );

  const onPointerMove = useCallback(
    (e: PointerEvent<HTMLDivElement>) => {
      if (reduced) return;
      const { clientX, clientY } = e;
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
      rafRef.current = requestAnimationFrame(() => compute(clientX, clientY));
    },
    [compute, reduced],
  );

  const reset = useCallback(() => {
    if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
    setActive(false);
    setTilt(REST);
  }, []);

  const engage = useCallback(() => {
    if (!reduced) setActive(true);
  }, [reduced]);

  return (
    <section className="relative w-full overflow-hidden bg-slate-100 px-6 py-20 dark:bg-slate-950 sm:py-28">
      <style>{`
        @keyframes hctilt_float {
          0%, 100% { transform: translateY(0); }
          50% { transform: translateY(-6px); }
        }
        @keyframes hctilt_shine {
          from { transform: translateX(-120%) skewX(-18deg); }
          to { transform: translateX(320%) skewX(-18deg); }
        }
        @media (prefers-reduced-motion: reduce) {
          .hctilt_badge { animation: none !important; }
          .hctilt_shine { display: none !important; }
        }
      `}</style>

      {/* atmosphere */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(60%_50%_at_50%_0%,rgba(99,102,241,0.18),transparent_70%)] dark:bg-[radial-gradient(60%_50%_at_50%_0%,rgba(129,140,248,0.16),transparent_70%)]"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 opacity-[0.4] [background-image:linear-gradient(to_right,rgba(100,116,139,0.12)_1px,transparent_1px),linear-gradient(to_bottom,rgba(100,116,139,0.12)_1px,transparent_1px)] [background-size:44px_44px] [mask-image:radial-gradient(70%_60%_at_50%_40%,black,transparent)]"
      />

      <div className="relative mx-auto flex max-w-md flex-col items-center">
        <p className="mb-2 text-xs font-semibold uppercase tracking-[0.25em] text-indigo-600 dark:text-indigo-400">
          Hover to interact
        </p>
        <h2 className="mb-10 text-center text-2xl font-semibold text-slate-900 dark:text-slate-100">
          Move your cursor across the card
        </h2>

        <div
          className="group"
          style={{ perspective: "1100px" }}
          onPointerMove={onPointerMove}
          onPointerEnter={engage}
          onPointerLeave={reset}
        >
          <div
            ref={cardRef}
            role="button"
            tabIndex={0}
            aria-label="Aurora Pro membership card — interactive 3D tilt on hover"
            onFocus={engage}
            onBlur={reset}
            className="relative w-[min(88vw,22rem)] rounded-3xl border border-white/60 bg-white/80 p-7 shadow-[0_18px_50px_-18px_rgba(15,23,42,0.45)] outline-none ring-indigo-500/60 backdrop-blur-xl transition-[transform,box-shadow] duration-200 ease-out will-change-transform focus-visible:ring-2 dark:border-white/10 dark:bg-slate-900/70 dark:shadow-[0_18px_60px_-18px_rgba(0,0,0,0.8)]"
            style={{
              transform: `rotateX(${tilt.rx}deg) rotateY(${tilt.ry}deg) scale(${
                active ? 1.03 : 1
              })`,
              transformStyle: "preserve-3d",
            }}
          >
            {/* dynamic glare that follows the cursor */}
            <div
              aria-hidden="true"
              className="pointer-events-none absolute inset-0 rounded-3xl opacity-0 transition-opacity duration-200 group-hover:opacity-100"
              style={{
                background: `radial-gradient(circle at ${tilt.gx}% ${tilt.gy}%, rgba(255,255,255,0.55), rgba(255,255,255,0) 42%)`,
                mixBlendMode: "overlay",
              }}
            />

            {/* header row — lifted in Z */}
            <div
              className="relative flex items-center justify-between"
              style={{ transform: "translateZ(48px)" }}
            >
              <span className="hctilt_badge inline-flex items-center gap-2 rounded-full bg-indigo-600/10 px-3 py-1 text-xs font-semibold text-indigo-700 ring-1 ring-inset ring-indigo-600/20 dark:bg-indigo-400/10 dark:text-indigo-300 dark:ring-indigo-400/20 [animation:hctilt_float_4s_ease-in-out_infinite]">
                <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
                Active
              </span>
              <svg
                viewBox="0 0 24 24"
                className="h-7 w-7 text-slate-900 dark:text-slate-100"
                fill="none"
                stroke="currentColor"
                strokeWidth="1.75"
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden="true"
              >
                <path d="M12 3l2.4 5.4L20 9.3l-4 4 1 5.7-5-2.9-5 2.9 1-5.7-4-4 5.6-.9z" />
              </svg>
            </div>

            {/* title block — deepest Z lift */}
            <div
              className="relative mt-8"
              style={{ transform: "translateZ(64px)" }}
            >
              <h3 className="text-xl font-semibold tracking-tight text-slate-900 dark:text-white">
                Aurora Pro
              </h3>
              <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                Unlimited projects, priority render queue, and early access to
                every experimental brush pack we ship.
              </p>
            </div>

            {/* price + chip — mid Z lift */}
            <div
              className="relative mt-7 flex items-end justify-between"
              style={{ transform: "translateZ(40px)" }}
            >
              <div>
                <span className="text-3xl font-bold text-slate-900 dark:text-white">
                  $18
                </span>
                <span className="ml-1 text-sm text-slate-500 dark:text-slate-400">
                  /month
                </span>
              </div>
              <div className="relative h-8 w-11 overflow-hidden rounded-md bg-gradient-to-br from-amber-300 to-amber-500 shadow-inner">
                <span className="absolute left-1 top-1 h-3 w-4 rounded-[3px] bg-amber-200/70" />
                <span className="hctilt_shine absolute inset-y-0 left-0 w-1/3 bg-white/50 [animation:hctilt_shine_2.6s_ease-in-out_infinite]" />
              </div>
            </div>

            {/* CTA — top Z lift */}
            <button
              type="button"
              className="relative mt-8 w-full rounded-xl bg-slate-900 px-4 py-3 text-sm font-semibold text-white shadow-lg transition-colors hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-900"
              style={{ transform: "translateZ(56px)" }}
            >
              Upgrade now
            </button>
          </div>
        </div>

        <p className="mt-8 text-center text-xs text-slate-500 dark:text-slate-500">
          Tilt follows your pointer. Keyboard focus and reduced-motion are
          respected.
        </p>
      </div>
    </section>
  );
}

Dependencies

None (React + Tailwind only).

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →