Tilt Card
3D perspective tilt on hover with cursor-tracked glare.
Preview
Premium
Tilt me
Move your cursor across the card to see 3D tilt + glare.
TSXcomponents/previews/motion/tilt-card.preview.tsx
"use client";
import { TiltCard } from "@/components/motion/tilt-card";
export function TiltCardPreview() {
return (
<div className="flex items-center justify-center p-6">
<TiltCard className="w-[280px] border border-border bg-card p-8">
<div className="text-xs uppercase tracking-wider text-muted-foreground">Premium</div>
<h3 className="mt-2 text-2xl font-semibold text-foreground">Tilt me</h3>
<p className="mt-3 text-sm text-muted-foreground">Move your cursor across the card to see 3D tilt + glare.</p>
</TiltCard>
</div>
);
}
TSXcomponents/motion/tilt-card.tsx
"use client";
// beui.dev/components/motion/tilt-card
import { motion, useMotionTemplate, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface TiltCardProps {
children: ReactNode;
max?: number;
glare?: boolean;
className?: string;
}
export function TiltCard({ children, max = 12, glare = true, className }: TiltCardProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const rx = useMotionValue(0);
const ry = useMotionValue(0);
const gx = useMotionValue(50);
const gy = useMotionValue(50);
const srx = useSpring(rx, SPRING_MOUSE);
const sry = useSpring(ry, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width;
const py = (e.clientY - rect.top) / rect.height;
ry.set((px - 0.5) * max);
rx.set((0.5 - py) * max);
gx.set(px * 100);
gy.set(py * 100);
};
const onLeave = () => {
rx.set(0);
ry.set(0);
};
const transform = useMotionTemplate`perspective(1000px) rotateX(${srx}deg) rotateY(${sry}deg)`;
const glareBg = useMotionTemplate`radial-gradient(circle at ${gx}% ${gy}%, var(--foreground), transparent 50%)`;
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ transform, transformStyle: "preserve-3d" }}
className={cn("relative overflow-hidden rounded-2xl will-change-transform", className)}
>
{children}
{glare && enabled ? (
<motion.div
aria-hidden
style={{ background: glareBg }}
className="pointer-events-none absolute inset-0 opacity-15"
/>
) : null}
</motion.div>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/tilt-card
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/hooks/use-hover-capable.ts
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/tilt-card.tsx
"use client";
// beui.dev/components/motion/tilt-card
import { motion, useMotionTemplate, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface TiltCardProps {
children: ReactNode;
max?: number;
glare?: boolean;
className?: string;
}
export function TiltCard({ children, max = 12, glare = true, className }: TiltCardProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const rx = useMotionValue(0);
const ry = useMotionValue(0);
const gx = useMotionValue(50);
const gy = useMotionValue(50);
const srx = useSpring(rx, SPRING_MOUSE);
const sry = useSpring(ry, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width;
const py = (e.clientY - rect.top) / rect.height;
ry.set((px - 0.5) * max);
rx.set((0.5 - py) * max);
gx.set(px * 100);
gy.set(py * 100);
};
const onLeave = () => {
rx.set(0);
ry.set(0);
};
const transform = useMotionTemplate`perspective(1000px) rotateX(${srx}deg) rotateY(${sry}deg)`;
const glareBg = useMotionTemplate`radial-gradient(circle at ${gx}% ${gy}%, var(--foreground), transparent 50%)`;
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ transform, transformStyle: "preserve-3d" }}
className={cn("relative overflow-hidden rounded-2xl will-change-transform", className)}
>
{children}
{glare && enabled ? (
<motion.div
aria-hidden
style={{ background: glareBg }}
className="pointer-events-none absolute inset-0 opacity-15"
/>
) : null}
</motion.div>
);
}
API Reference
max?number12glare?booleantrueclassName?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated