liten

Tilt Card

A card that tilts toward the pointer in 3D and catches a top-lit glare that tracks the cursor.

Built to feel real

Move your cursor over the card. It tilts toward you in 3D, catches the light from above, and settles with a spring when you leave.

Two spring pairs drive the tilt and the glare, so the card has momentum and settles instead of snapping to the raw pointer position. The glare only ever moves across the top of the card, keeping the light source physically honest. Built with Motion.

Installation

Complete the shared Setup first, then add Motion.

Terminal
bun add motion

Copy the component into components/ui/tilt-card.tsx.

components/ui/tilt-card.tsx
'use client';import * as React from 'react';import {  motion,  useMotionTemplate,  useReducedMotion,  useSpring,  useTransform,} from 'motion/react';import { cn } from '@/lib/cn';const FOCUS =  'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--tilt-accent)]';export type TiltCardProps = {  maxTilt?: number;  glare?: boolean;  accent?: string;  children?: React.ReactNode;  className?: string;};export function TiltCard({  maxTilt = 10,  glare = true,  accent = '#f0883e',  children,  className,}: TiltCardProps) {  const reduce = useReducedMotion();  const ref = React.useRef<HTMLDivElement>(null);  const spring = { stiffness: 200, damping: 22, mass: 0.6 } as const;  const rotateX = useSpring(0, spring);  const rotateY = useSpring(0, spring);  const lift = useSpring(0, spring);  const gx = useSpring(50, spring);  const gy = useSpring(0, spring);  const glareOpacity = useSpring(0, { stiffness: 160, damping: 26 });  const y = useTransform(lift, (l) => l * -6);  const glareBg = useMotionTemplate`radial-gradient(60% 60% at ${gx}% ${gy}%, rgba(255,255,255,0.22), transparent 60%)`;  const onMove = (e: React.PointerEvent<HTMLDivElement>) => {    if (reduce) return;    const el = ref.current;    if (!el) return;    const r = el.getBoundingClientRect();    const px = (e.clientX - r.left) / r.width - 0.5;    const py = (e.clientY - r.top) / r.height - 0.5;    rotateY.set(px * maxTilt * 2);    rotateX.set(-py * maxTilt * 2);    gx.set((px + 0.5) * 100);    gy.set((py + 0.5) * 100);  };  const onEnter = () => {    if (reduce) return;    lift.set(1);    glareOpacity.set(glare ? 1 : 0);  };  const onLeave = () => {    rotateX.set(0);    rotateY.set(0);    lift.set(0);    glareOpacity.set(0);  };  return (    <div      style={{ perspective: 900, '--tilt-accent': accent } as React.CSSProperties}      className={cn('relative', className)}    >      <motion.div        ref={ref}        tabIndex={0}        onPointerMove={onMove}        onPointerEnter={onEnter}        onPointerLeave={onLeave}        whileTap={reduce ? undefined : { scale: 0.985 }}        style={{          rotateX,          rotateY,          y,          borderRadius: 14,          transformStyle: 'preserve-3d',        }}        className={cn(          'bloom-edge group relative overflow-hidden',          'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-900',          'dark:from-[#1d1d1d] dark:to-[#161716] dark:text-white',          'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_8px_22px_-12px_rgba(0,0,0,0.18)]',          'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05),0_1px_2px_0_rgba(0,0,0,0.4),0_12px_28px_-14px_rgba(0,0,0,0.6)]',          FOCUS,        )}      >        {glare && (          <motion.div            aria-hidden            className="pointer-events-none absolute inset-0 z-10"            style={{ background: glareBg, opacity: glareOpacity }}          />        )}        <div className="relative z-0">{children ?? <TiltCardSample />}</div>      </motion.div>    </div>  );}function TiltCardSample() {  return (    <div className="w-[300px] max-w-full p-6">      <span className="bloom-edge grid size-10 place-items-center rounded-[10px] bg-black/[0.03] text-neutral-700 dark:bg-black/20 dark:text-neutral-200 shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.04)] dark:shadow-[inset_0_1px_3px_0_rgba(0,0,0,0.5)]">        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>          <path d="M13 2 3 14h7l-1 8 10-12h-7z" />        </svg>      </span>      <h3 className="mt-4 text-[16px] font-semibold tracking-[-0.01em]">        Built to feel real      </h3>      <p className="mt-1.5 text-[13px] leading-relaxed text-neutral-500 dark:text-[#929292]">        Move your cursor over the card. It tilts toward you in 3D, catches the        light from above, and settles with a spring when you leave.      </p>    </div>  );}

Usage

Example.tsx
import { TiltCard } from '@/components/ui/tilt-card';

export default function Example() {
  return <TiltCard />;
}

Examples

Pass any children to tilt custom content, like a media card.

New

Aurora Pack

A media card that tilts toward the cursor, great for product and gallery grids.

A grid of tilt cards with the glare turned off, for a calmer effect.

Fast

Springs settle in under 200ms.

Deep

Real perspective, lit from above.

Calm

Tilt stops under reduced motion.

Props

PropTypeDefaultDescription
maxTiltnumber10Maximum tilt in degrees at the card's edges.
glarebooleantrueShow the moving top-lit glare.
accentstring"#f0883e"Accent for the keyboard focus outline only.
childrenReact.ReactNode-Custom content; falls back to a sample card.
classNamestring-Forwarded to the outer wrapper.
On this page0%