Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/tilt-card.jsonIt adds these packages for you:
clsxtailwind-mergeThe source lands in your components folder as
tilt-card.tsx. It's yours: change anything.
Usage
import { TiltCard, TiltLayer } from "@/components/tilt-card"
export function Feature() {
return (
<TiltCard className="max-w-xs p-6">
<TiltLayer depth={12}>
<h3 className="font-semibold">Northern Lights Pass</h3>
<p className="text-sm text-muted-foreground">Seven nights above the Arctic Circle.</p>
</TiltLayer>
</TiltCard>
)
}Source
One file, 148 lines, nothing hidden. Read it before you install it.
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TiltCardProps extends React.ComponentProps<"div"> {
/** Largest tilt, in degrees, at the card's edges. */
maxTilt?: number;
/** A soft sheen that follows the pointer across the surface. */
glare?: boolean;
/** Scale while hovered, e.g. 1.02. */
hoverScale?: number;
}
export interface TiltLayerProps extends React.ComponentProps<"div"> {
/**
* How far this layer drifts against the tilt, in pixels at the card's edge.
* Bigger feels closer: a background might take -6, a floating badge 14.
*/
depth?: number;
}
// How quickly the tilt catches up with the pointer (ms to close ~63% of the gap).
// A frame-rate-independent ease written out here, so the card needs no animation library.
const FOLLOW_MS = 90;
/**
* A layer inside a `TiltCard` that drifts with the tilt, so the card reads as
* having depth: put a background, the content and a floating badge on
* different depths.
*/
export function TiltLayer({ depth = 10, className, style, ...props }: TiltLayerProps) {
return (
<div
className={cn("[transform:translate3d(calc(var(--tilt-px,0)*var(--tilt-depth)),calc(var(--tilt-py,0)*var(--tilt-depth)),0)] motion-reduce:[transform:none]", className)}
style={{ "--tilt-depth": `${depth}px`, ...style } as React.CSSProperties}
{...props}
/>
);
}
/**
* A card that tilts in 3D toward the pointer, lifts, and catches the light.
*
* - The tilt glides after the pointer and settles back when it leaves; it is
* written to CSS variables, so moving never re-renders React. No animation
* library needed.
* - `TiltLayer` children drift by their own `depth` for parallax, and the
* shadow falls away from the raised edge.
* - Mouse and pen only; touch scrolling is never hijacked.
* - Flat and still under `prefers-reduced-motion`. The sheen is decorative and
* hidden from assistive technology.
*/
export function TiltCard({
maxTilt = 8,
glare = true,
hoverScale = 1.02,
className,
style,
children,
onPointerMove,
onPointerLeave,
...props
}: TiltCardProps) {
const ref = React.useRef<HTMLDivElement>(null);
// Where the tilt is (-1..1 across the card) and how lifted (0..1), where they are heading, and the frame moving them.
const follow = React.useRef({ x: 0, y: 0, lift: 0, tx: 0, ty: 0, tlift: 0, frame: 0, last: 0 });
React.useEffect(() => () => cancelAnimationFrame(follow.current.frame), []);
const run = () => {
const f = follow.current;
if (f.frame) return;
f.last = 0;
const step = (now: number) => {
const node = ref.current;
// The first frame, and any long pause, count as one ordinary frame.
const dt = f.last ? Math.min(Math.max(now - f.last, 0), 64) : 16;
const k = 1 - Math.exp(-dt / FOLLOW_MS);
f.last = now;
f.x += (f.tx - f.x) * k;
f.y += (f.ty - f.y) * k;
f.lift += (f.tlift - f.lift) * k;
const settled = Math.abs(f.tx - f.x) < 0.002 && Math.abs(f.ty - f.y) < 0.002 && Math.abs(f.tlift - f.lift) < 0.002;
if (settled) {
f.x = f.tx;
f.y = f.ty;
f.lift = f.tlift;
}
node?.style.setProperty("--tilt-px", f.x.toFixed(4));
node?.style.setProperty("--tilt-py", f.y.toFixed(4));
node?.style.setProperty("--tilt-lift", f.lift.toFixed(4));
f.frame = settled ? 0 : requestAnimationFrame(step);
};
f.frame = requestAnimationFrame(step);
};
return (
<div
ref={ref}
onPointerMove={(event) => {
onPointerMove?.(event);
const node = ref.current;
if (!node || event.pointerType === "touch" || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const rect = node.getBoundingClientRect();
const f = follow.current;
f.tx = Math.max(-1, Math.min(1, ((event.clientX - rect.left) / rect.width) * 2 - 1));
f.ty = Math.max(-1, Math.min(1, ((event.clientY - rect.top) / rect.height) * 2 - 1));
f.tlift = 1;
node.dataset.tilting = "";
run();
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
const node = ref.current;
if (!node) return;
delete node.dataset.tilting;
const f = follow.current;
f.tx = 0;
f.ty = 0;
f.tlift = 0;
run();
}}
style={{ "--tilt-max": `${maxTilt}deg`, "--tilt-scale": hoverScale - 1, ...style } as React.CSSProperties}
className={cn(
"group/tilt relative isolate rounded-2xl border border-border bg-card text-card-foreground will-change-transform",
"[transform:perspective(1000px)_rotateX(calc(var(--tilt-py,0)*-1*var(--tilt-max)))_rotateY(calc(var(--tilt-px,0)*var(--tilt-max)))_scale(calc(1+var(--tilt-lift,0)*var(--tilt-scale)))]",
// The shadow deepens as the card lifts and falls away from the raised edge.
"shadow-[calc(var(--tilt-px,0)*-14px)_calc(var(--tilt-py,0)*-14px+calc(var(--tilt-lift,0)*20px+2px))_calc(var(--tilt-lift,0)*36px+6px)_calc(var(--tilt-lift,0)*-14px-2px)_rgb(0_0_0/calc(0.1+var(--tilt-lift,0)*0.22))]",
"motion-reduce:[transform:none]!",
className,
)}
{...props}
>
{children}
{glare && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit] opacity-[var(--tilt-lift,0)] mix-blend-soft-light motion-reduce:hidden"
>
<div className="absolute inset-0 [background:radial-gradient(60%_60%_at_calc(50%+var(--tilt-px,0)*50%)_calc(50%+var(--tilt-py,0)*50%),rgb(255_255_255/0.55),transparent_70%)]" />
<div className="absolute inset-0 [background:linear-gradient(calc(115deg+var(--tilt-px,0)*25deg),transparent_35%,rgb(255_255_255/0.22)_50%,transparent_65%)]" />
</div>
)}
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| maxTilt | number | 8 | Largest tilt in degrees, at the card's edges. |
| glare | boolean | true | Show a sheen that follows the pointer. |
| hoverScale | number | 1.02 | Scale while hovered. |
| TiltLayer depth | number | 10 | On TiltLayer children: pixels a layer drifts at the edge. Negative sits behind. |
| ...props | HTMLAttributes<HTMLDivElement> | — | Any div attribute. Your className is merged last. |
Accessibility
- The tilt, layers and sheen are visual only; the sheen is aria-hidden.
- The tilt eases in CSS variables, so moving never re-renders React.
- Touch scrolling is never captured: only mouse and pen tilt it.
- Flat and still under prefers-reduced-motion.
Changelog
- v1.0.0Latest
First release.