Tilt Card
A bounded spring tilt with stable pointer tracking and reduced-motion support.
Make it yours.
Install the editable source and its dependencies into your React + Tailwind project.
Scroll horizontally for long lines
pnpm dlx shadcn@4.0.8 add http://localhost:3000/r/tilt-card.jsonUse it
Scroll horizontally for long lines
"use client";
import { TiltCard } from "@/components/jez-ui/ui/tilt-card";
export default function Example() {
return (
<>
<TiltCard className="w-full max-w-sm border-0 bg-primary p-0 text-primary-foreground">
<div className="flex min-h-80 flex-col justify-between p-8">
<div className="flex justify-between text-sm">
<span>Jez Studio</span>
<span>↗</span>
</div>
<h3 className="text-5xl font-medium leading-[1.05] tracking-tight">
A different
<br />
perspective.
</h3>
<div className="flex justify-between border-t border-white/30 pt-5 text-xs">
<span>Move your pointer.</span>
<span>Make it yours.</span>
</div>
</div>
</TiltCard>
</>
);
}
Props & types
Scroll horizontally for long lines
Omit<
React.ComponentProps<"div">,
keyof {
children: React.ReactNode;
className?: string;
maxTilt?: number;
}
> & {
children: React.ReactNode;
className?: string;
maxTilt?: number;
}Source
View 2 source files
registry/ui/tilt-card.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { cn } from "./utils";
import { motion, useReducedMotion, useSpring } from "motion/react";
export function TiltCard({
children,
className,
maxTilt = 8,
...rootProps
}: Omit<
React.ComponentProps<"div">,
keyof {
children: React.ReactNode;
className?: string;
maxTilt?: number;
}
> & {
children: React.ReactNode;
className?: string;
maxTilt?: number;
}) {
const reduce = useReducedMotion();
const rotateX = useSpring(0, { stiffness: 210, damping: 25 });
const rotateY = useSpring(0, { stiffness: 210, damping: 25 });
const reset = () => {
rotateX.set(0);
rotateY.set(0);
};
React.useEffect(() => {
if (reduce) {
rotateX.jump(0);
rotateY.jump(0);
}
}, [reduce, rotateX, rotateY]);
return (
<div
{...rootProps}
style={{ perspective: 1000, ...rootProps.style }}
onPointerMove={(e) => {
rootProps.onPointerMove?.(e);
if (e.defaultPrevented) return;
if (reduce || e.pointerType !== "mouse") return;
// Measure the stationary wrapper, not the surface being transformed.
const r = e.currentTarget.getBoundingClientRect();
rotateX.set(
-Math.max(-1, Math.min(1, ((e.clientY - r.top) / r.height) * 2 - 1)) *
maxTilt,
);
rotateY.set(
Math.max(-1, Math.min(1, ((e.clientX - r.left) / r.width) * 2 - 1)) *
maxTilt,
);
}}
onPointerLeave={(event) => {
rootProps.onPointerLeave?.(event);
if (!event.defaultPrevented) reset();
}}
onPointerCancel={(event) => {
rootProps.onPointerCancel?.(event);
if (!event.defaultPrevented) reset();
}}
>
<motion.div
style={{ rotateX, rotateY, transformStyle: "preserve-3d" }}
className={cn(
"rounded-xl border border-border bg-background p-8",
className,
)}
>
{children}
</motion.div>
</div>
);
}
registry/ui/utils.ts
Scroll horizontally for long lines
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...values: ClassValue[]) {
return twMerge(clsx(values));
}
Dependencies
motion@13.2.0 · clsx@2.1.1 · tailwind-merge@3.5.0
Accessibility & behaviour
Respect reduced-motion preferences. Decorative effects must not carry essential information. WebGL scenes include a static fallback and only render while visible.
Read the accessibility and performance guide →