Skip to content

Tilt Card

3D tilt card for React: leans toward the pointer with a soft sheen and a drifting photo. Lightweight, no animation library.

  • Micro-interactions
  • Travel & hospitality
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. Pointer only; touch and reduced motion keep the card flat. Photos are plain URLs you pass in; if one fails to load, a soft gradient stays in its place. The demo uses /demo/*.webp, swap in your own files.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/tilt-card.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/micro-interactions/tilt-card.tsx
1/**
2 * TiltCard, a photo card that leans toward the pointer in 3D, with a soft
3 * light sheen that follows it and the picture drifting slightly behind.
4 *
5 * Props: src, title, caption, width (px, default 260), ratio (height / width,
6 * default 1.3), max (max tilt in degrees, default 11), sheen (boolean).
7 * CSS vars: --fd-tc-radius.
8 *
9 * Browser support: all evergreen browsers. Pointer values are written as CSS
10 * variables straight to the element, so moving the pointer never re-renders.
11 * Accessibility: decorative motion only; the card content is plain text and an
12 * image. Reduced motion and touch: the card stays flat.
13 */
14import { useRef, useState } from "react";
15import type { PointerEvent } from "react";
16
17export interface TiltCardProps {
18 src?: string;
19 title?: string;
20 caption?: string;
21 width?: number;
22 ratio?: number;
23 max?: number;
24 sheen?: boolean;
25 className?: string;
26}
27
28export function TiltCard({
29 src,
30 title,
31 caption,
32 width = 260,
33 ratio = 1.3,
34 max = 11,
35 sheen = true,
36 className,
37}: TiltCardProps) {
38 const el = useRef<HTMLDivElement>(null);
39 const [ok, setOk] = useState(true);
40
41 const move = (e: PointerEvent<HTMLDivElement>) => {
42 const node = el.current;
43 if (!node || e.pointerType === "touch") return;
44 const r = node.getBoundingClientRect();
45 const px = (e.clientX - r.left) / r.width;
46 const py = (e.clientY - r.top) / r.height;
47 node.style.setProperty("--rx", `${((0.5 - py) * 2 * max).toFixed(2)}deg`);
48 node.style.setProperty("--ry", `${((px - 0.5) * 2 * max).toFixed(2)}deg`);
49 node.style.setProperty("--mx", `${(px * 100).toFixed(1)}%`);
50 node.style.setProperty("--my", `${(py * 100).toFixed(1)}%`);
51 node.style.setProperty("--px", `${((px - 0.5) * -14).toFixed(1)}px`);
52 node.style.setProperty("--py", `${((py - 0.5) * -14).toFixed(1)}px`);
53 node.dataset.live = "";
54 };
55 const leave = () => {
56 const node = el.current;
57 if (!node) return;
58 delete node.dataset.live;
59 for (const k of ["--rx", "--ry", "--px", "--py"]) node.style.removeProperty(k);
60 };
61
62 return (
63 <div className={`fd-tc${className ? ` ${className}` : ""}`} style={{ width }}>
64 <style>{css}</style>
65 <div
66 ref={el}
67 className="fd-tc-card"
68 data-sheen={sheen || undefined}
69 style={{ height: Math.round(width * ratio) }}
70 onPointerMove={move}
71 onPointerLeave={leave}
72 onPointerCancel={leave}
73 >
74 <span className="fd-tc-photo">
75 {src && ok ? <img className="fd-tc-img" src={src} alt="" draggable={false} decoding="async" onError={() => setOk(false)} /> : null}
76 </span>
77 <span className="fd-tc-shade" />
78 <span className="fd-tc-sheen" />
79 {(title || caption) && (
80 <span className="fd-tc-text">
81 {title ? <span className="fd-tc-title">{title}</span> : null}
82 {caption ? <span className="fd-tc-caption">{caption}</span> : null}
83 </span>
84 )}
85 </div>
86 </div>
87 );
88}
89
90const FONT = `ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif`;
91const css = `
92.fd-tc,.fd-tc *,.fd-tc *::before,.fd-tc *::after{box-sizing:border-box}
93.fd-tc{--fd-tc-radius:24px;--fd-ease-out:cubic-bezier(0.22,1,0.36,1);display:block;margin:0;padding:0;perspective:900px;font:400 14px/1.4 ${FONT};color:#fff}
94.fd-tc-card{position:relative;display:block;margin:0;padding:0;border-radius:var(--fd-tc-radius);overflow:hidden;background:linear-gradient(160deg,#dcd0c8,#9aa3b8);box-shadow:0 1px 2px rgba(0,0,0,.1),0 22px 40px -16px rgba(0,0,0,.4);transform:rotateX(var(--rx,0deg)) rotateY(var(--ry,0deg)) scale(1);transition:transform 700ms var(--fd-ease-out),box-shadow 700ms var(--fd-ease-out);transform-style:preserve-3d;will-change:transform}
95.fd-tc-card[data-live]{transform:rotateX(var(--rx)) rotateY(var(--ry)) scale(1.03);transition:transform 120ms linear,box-shadow 300ms;box-shadow:0 1px 2px rgba(0,0,0,.1),0 34px 50px -18px rgba(0,0,0,.5)}
96.fd-tc-photo{position:absolute;inset:-14px;display:block;margin:0;padding:0;transform:translate3d(var(--px,0px),var(--py,0px),0);transition:transform 700ms var(--fd-ease-out)}
97.fd-tc-card[data-live] .fd-tc-photo{transition:transform 120ms linear}
98.fd-tc-img{position:absolute;inset:0;display:block;width:100%;height:100%;margin:0;padding:0;border:0;object-fit:cover;pointer-events:none}
99.fd-tc-shade{position:absolute;inset:0;display:block;background:linear-gradient(180deg,transparent 55%,rgba(0,0,0,.5));pointer-events:none}
100.fd-tc-sheen{position:absolute;inset:0;display:none;pointer-events:none;opacity:0;background:radial-gradient(420px circle at var(--mx,50%) var(--my,50%),rgba(255,255,255,.32),transparent 60%);mix-blend-mode:soft-light;transition:opacity 400ms}
101.fd-tc-card[data-sheen] .fd-tc-sheen{display:block}
102.fd-tc-card[data-live] .fd-tc-sheen{opacity:1}
103.fd-tc-text{position:absolute;left:20px;right:20px;bottom:18px;display:flex;flex-direction:column;gap:3px;margin:0;padding:0;transform:translateZ(30px)}
104.fd-tc-title{display:block;margin:0;padding:0;font:600 19px/1.2 ${FONT};letter-spacing:-.01em}
105.fd-tc-caption{display:block;margin:0;padding:0;color:rgba(255,255,255,.78);font:400 13px/1.3 ${FONT}}
106@media (prefers-reduced-motion:reduce){.fd-tc-card,.fd-tc-photo{transition:none;transform:none!important}}
107@media (hover:none){.fd-tc-card,.fd-tc-photo{transform:none!important}}
108`;
109
110export function Demo() {
111 return (
112 <div className="fd-tcd">
113 <style>{demoCss}</style>
114 <TiltCard src="/demo/eagle-p.webp" title="Golden eagle" caption="Aquila chrysaetos" />
115 </div>
116 );
117}
118
119const demoCss = `
120.fd-tcd,.fd-tcd *,.fd-tcd *::before,.fd-tcd *::after{box-sizing:border-box}
121.fd-tcd{display:flex;align-items:center;justify-content:center;width:100%;min-height:100vh;margin:0;padding:48px 20px;background:#ececec;font:400 14px/1.4 ${FONT};letter-spacing:normal;overflow:hidden}
122`;

More Micro-interactions. Same file contract.

Card Stack, micro interactions preview: Card stack for React: photo cards you throw to the back with a drag or a flick, tap to pull one forward

Card Stack

Card stack for React: photo cards you throw to the back with a drag or a flick, tap to pull one forward. No dependencies.

Photo Sleeves, micro interactions preview: Photo album sleeves for React: pictures peek out, lift on hover and fan open on click

Photo Sleeves

Photo album sleeves for React: pictures peek out, lift on hover and fan open on click. Frosted pocket, zero dependencies.

Pull Button, micro interactions preview: Magnetic pull button for React: leans toward your cursor, the label moves further, then springs back

Pull Button

Magnetic pull button for React: leans toward your cursor, the label moves further, then springs back. A tiny rAF spring, no library.

Questions about Tilt Card

Run npx shadcn@latest add https://freedesignmd.com/r/tilt-card.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.

3D tilt card for React: leans toward the pointer with a soft sheen and a drifting photo. Lightweight, no animation library. It is a free, MIT-licensed React micro-interactions component from the freedesignmd library.

Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.

All evergreen browsers. Pointer only; touch and reduced motion keep the card flat. Photos are plain URLs you pass in; if one fails to load, a soft gradient stays in its place. The demo uses /demo/*.webp, swap in your own files.

Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.