3D Tilt Card
Move your cursor across the surface — spring lag, a sliding glare, and edge-lit shimmer.
experimental
A card that tracks the cursor in real time, tilting on X/Y with a spring 'magnet lag', a radial glare spot that slides across the surface, and an edge-lit border shimmer that brightens toward the cursor vector. Children opt into translateZ parallax via data-depth. Keyboard users get a static, focus-visible-ringed surface; reduced-motion users get a CSS-only border/accent hover affordance with no JS motion. asChild renders the card as a real button/anchor.
Move your cursor across the surface — spring lag, a sliding glare, and edge-lit shimmer.
1. Register the namespace (once per project):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/card-3d-tilt<Card3dTilt />| Prop | Type | Default | Description |
|---|---|---|---|
| children | React.ReactNode | — | Card contents. Descendants with `data-depth` (0–1) parallax on translateZ. |
| tiltMax | number | 12 | Maximum rotation in degrees on each axis. |
| glare | boolean | true | Enable/disable the radial glare highlight layer. |
| glareOpacity | number | 0.18 | Peak opacity of the glare spot at cursor center (0–1). |
| scale | number | 1.03 | Scale factor applied on hover entry. |
| perspective | number | 800 | CSS perspective depth in px — lower = more dramatic tilt. |
| spring | { stiffness: number; damping: number; } | { stiffness: 280, damping: 28 } | Motion spring config for rotation and glare follow. |
| asChild | boolean | false | Render the single child element as the root (lightweight Slot — no extra dependency). Use to make the card a `<button>` or `<a>`; the host element then carries its own native role. |
"use client";
import {
Children,
cloneElement,
isValidElement,
useEffect,
useRef,
useState,
type ReactElement,
type Ref,
} from "react";
import {
motion,
useMotionValue,
useSpring,
useReducedMotion,
useMotionValueEvent,
type HTMLMotionProps,
} from "motion/react";
import styles from "./card-3d-tilt.module.css";
export type Card3DTiltProps = Omit<
HTMLMotionProps<"div">,
"style" | "ref" | "children"
> & {
/** Card contents. Descendants with `data-depth` (0–1) parallax on translateZ. */
children?: React.ReactNode;
/** Maximum rotation in degrees on each axis. */
tiltMax?: number;
/** Enable/disable the radial glare highlight layer. */
glare?: boolean;
/** Peak opacity of the glare spot at cursor center (0–1). */
glareOpacity?: number;
/** Scale factor applied on hover entry. */
scale?: number;
/** CSS perspective depth in px — lower = more dramatic tilt. */
perspective?: number;
/** Motion spring config for rotation and glare follow. */
spring?: { stiffness: number; damping: number };
/**
* Render the single child element as the root (lightweight Slot — no extra
* dependency). Use to make the card a `<button>` or `<a>`; the host element
* then carries its own native role.
*/
asChild?: boolean;
};
const DEFAULT_SPRING = { stiffness: 280, damping: 28 };
/**
* Smooth resistance near the tilt limit — values past 80% of the range are
* progressively eased so the card never slams into a hard wall.
* Input/output normalized to [-1, 1].
*/
function softClamp(n: number) {
const sign = Math.sign(n);
const a = Math.min(Math.abs(n), 1);
if (a <= 0.8) return n;
const over = (a - 0.8) / 0.2; // 0..1 past the knee
const eased = 0.8 + 0.2 * (1 - (1 - over) * (1 - over)); // ease-out
return sign * eased;
}
/** Assign a value to a callback or object ref (no-op for null). */
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
if (typeof ref === "function") ref(value);
else if (ref && typeof ref === "object") {
(ref as React.MutableRefObject<T | null>).current = value;
}
}
/** Merge two refs into one callback ref so both receive the node. */
function mergeRefs<T>(a: Ref<T> | undefined, b: Ref<T> | undefined) {
return (node: T | null) => {
assignRef(a, node);
assignRef(b, node);
};
}
/** Compose two event handlers; the user's runs first, then ours. */
function composeHandlers<E>(
theirs: ((e: E) => void) | undefined,
ours: (e: E) => void
) {
return (e: E) => {
theirs?.(e);
ours(e);
};
}
export function Card3DTilt({
children,
className = "",
tiltMax = 12,
glare = true,
glareOpacity = 0.18,
scale = 1.03,
perspective = 800,
spring = DEFAULT_SPRING,
asChild = false,
role,
...props
}: Card3DTiltProps) {
const ref = useRef<HTMLDivElement>(null);
const glareRef = useRef<HTMLDivElement>(null);
const edgeRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLElement>(null);
// Mount + reduced-motion gate (contract snippet) — server and first client
// render are byte-identical static; animation only turns on post-hydration.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion;
const animate = mounted && !reduce;
// Entrance: single play, triggered once by IntersectionObserver.
const [entered, setEntered] = useState(false);
useEffect(() => {
if (!animate || !ref.current) return;
const el = ref.current;
const io = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setEntered(true);
io.disconnect();
}
},
{ threshold: 0.15 }
);
io.observe(el);
return () => io.disconnect();
}, [animate]);
// Spring-driven rotation/scale; glare position follows snappily.
const rotX = useSpring(0, spring);
const rotY = useSpring(0, spring);
const sc = useSpring(1, spring);
const glareX = useMotionValue(50);
const glareY = useMotionValue(50);
const glareO = useSpring(0, spring);
const edgeI = useSpring(0, spring);
// 0 idle -> 1 hovered; depth children translateZ proportionally to this.
const lift = useSpring(0, spring);
const pressed = useRef(false);
// Apply parallax to opted-in [data-depth] children. Each child floats on
// translateZ = depth * lift * MAX_LIFT_PX, so content lifts out of the
// surface without any extra wrapper component. Updated imperatively to keep
// pointer moves off the render path.
const MAX_LIFT_PX = 40;
useMotionValueEvent(lift, "change", (v) => {
const root = contentRef.current;
if (!root) return;
const nodes = root.querySelectorAll<HTMLElement>("[data-depth]");
nodes.forEach((node) => {
const depth = Number(node.dataset.depth);
if (Number.isNaN(depth)) return;
node.style.transform = `translateZ(${depth * v * MAX_LIFT_PX}px)`;
});
});
// Push glare/edge values to CSS custom props imperatively — no re-render per
// pointer move; work stays on transform/opacity.
useMotionValueEvent(glareX, "change", (v) => {
glareRef.current?.style.setProperty("--glare-x", `${v}%`);
});
useMotionValueEvent(glareY, "change", (v) => {
glareRef.current?.style.setProperty("--glare-y", `${v}%`);
});
useMotionValueEvent(glareO, "change", (v) => {
glareRef.current?.style.setProperty("--glare-opacity", `${v}`);
});
useMotionValueEvent(edgeI, "change", (v) => {
edgeRef.current?.style.setProperty("--edge-intensity", `${v}`);
});
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!animate || !ref.current) return;
const rect = ref.current.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width; // 0..1
const py = (e.clientY - rect.top) / rect.height; // 0..1
const nx = px * 2 - 1; // -1..1
const ny = py * 2 - 1;
const limit = pressed.current ? tiltMax * 0.5 : tiltMax;
// invert Y so cursor-up tilts the top toward the viewer
rotX.set(softClamp(-ny) * limit);
rotY.set(softClamp(nx) * limit);
if (glare) {
glareX.set(px * 100);
glareY.set(py * 100);
glareO.set(glareOpacity);
}
// edge brightens as the cursor nears any edge (further from center = brighter)
const edgeStrength = Math.min(1, Math.max(Math.abs(nx), Math.abs(ny)));
edgeI.set(0.35 + edgeStrength * 0.65);
}
function handlePointerEnter() {
if (!animate) return;
sc.set(pressed.current ? 0.99 : scale);
lift.set(1);
}
function handlePointerLeave() {
if (!animate) return;
pressed.current = false;
// glare fades first (~180ms via CSS) then rotation returns — "slides off".
glareO.set(0);
edgeI.set(0);
rotX.set(0);
rotY.set(0);
sc.set(1);
lift.set(0);
}
function handlePointerDown() {
if (!animate) return;
pressed.current = true;
sc.set(0.99);
}
function handlePointerUp() {
if (!animate) return;
pressed.current = false;
sc.set(scale);
}
// Standalone (non-interactive, non-asChild) cards get role="article".
// asChild cards let the host element carry its own native role.
const resolvedRole = role ?? (asChild ? undefined : "article");
const overlays = (
<>
{glare && (
<div ref={glareRef} className={styles.glare} aria-hidden="true" />
)}
<div ref={edgeRef} className={styles.edge} aria-hidden="true" />
</>
);
// asChild: merge our root props/handlers onto the single child element so it
// becomes the tilt root (e.g. a real <button>/<a>). Lightweight Slot — no dep.
if (asChild) {
const child = Children.only(children);
if (!isValidElement(child)) return null;
const el = child as ReactElement<{
className?: string;
children?: React.ReactNode;
ref?: Ref<HTMLElement>;
}> & { ref?: Ref<HTMLElement> };
// Merge our measuring ref onto the child so IntersectionObserver and
// getBoundingClientRect see the real root; React 19 exposes ref via props,
// older via el.ref — cover both.
const childRef = (el.props as { ref?: Ref<HTMLElement> }).ref ?? el.ref;
const childProps = el.props as React.HTMLAttributes<HTMLElement>;
return cloneElement(
el,
{
...props,
ref: mergeRefs(childRef, ref as Ref<HTMLElement>),
className: [styles.root, animate ? styles.animated : "", el.props.className, className]
.filter(Boolean)
.join(" "),
// Compose with any handlers the child (or user via props) already has.
onPointerMove: composeHandlers(
childProps.onPointerMove ?? props.onPointerMove,
handlePointerMove
),
onPointerEnter: composeHandlers(
childProps.onPointerEnter ?? props.onPointerEnter,
handlePointerEnter
),
onPointerLeave: composeHandlers(
childProps.onPointerLeave ?? props.onPointerLeave,
handlePointerLeave
),
onPointerDown: composeHandlers(
childProps.onPointerDown ?? props.onPointerDown,
handlePointerDown
),
onPointerUp: composeHandlers(
childProps.onPointerUp ?? props.onPointerUp,
handlePointerUp
),
} as React.HTMLAttributes<HTMLElement> & { ref?: Ref<HTMLElement> },
<>
{overlays}
<span ref={contentRef} className={styles.content}>
{el.props.children}
</span>
</>
);
}
return (
<motion.div
{...props}
ref={ref}
className={[styles.root, animate ? styles.animated : "", className]
.filter(Boolean)
.join(" ")}
role={resolvedRole}
onPointerMove={composeHandlers(props.onPointerMove, handlePointerMove)}
onPointerEnter={composeHandlers(props.onPointerEnter, handlePointerEnter)}
onPointerLeave={composeHandlers(props.onPointerLeave, handlePointerLeave)}
onPointerDown={composeHandlers(props.onPointerDown, handlePointerDown)}
onPointerUp={composeHandlers(props.onPointerUp, handlePointerUp)}
style={
animate
? {
perspective,
transformStyle: "preserve-3d",
rotateX: rotX,
rotateY: rotY,
scale: sc,
}
: undefined
}
initial={animate ? { opacity: 0, y: 12 } : false}
animate={
animate ? (entered ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }) : undefined
}
// Entrance is a fade+lift (CSS-token-scale UI transition ~300ms, expressed
// in seconds for Motion). Spring (above) owns the hover motion, not this.
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
>
{overlays}
<div ref={contentRef as React.RefObject<HTMLDivElement>} className={styles.content}>
{children}
</div>
</motion.div>
);
}