# Tilt Card

> A card that leans toward the pointer and catches the light with a soft moving glare.

An animated React component from KeyframeUI, installed as source with the shadcn CLI. Built with React and Tailwind CSS.

- Docs and live preview: https://keyframeui.com/components/tilt-card
- Tags: Card, Interaction, 3D
- npm dependencies: clsx, tailwind-merge

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/tilt-card.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/tilt-card
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/tilt-card.json
```

## Usage

The main demo shows typical usage.

### Default

A single card with the default tilt and glare.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/tilt-card-demo
```

```tsx title="components/tilt-card-demo.tsx"
import { TiltCard } from "@/components/ui/tilt-card";

export function TiltCardDemo() {
  return (
    <TiltCard className="w-72 border border-zinc-200 bg-linear-to-br from-white to-zinc-100 p-6 shadow-xl shadow-zinc-900/5 dark:border-zinc-800 dark:from-zinc-900 dark:to-zinc-950 dark:shadow-black/40">
      <p className="font-mono text-xs uppercase tracking-widest text-zinc-500">
        Keyframe
      </p>
      <h3 className="mt-20 text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
        Move your pointer across me
      </h3>
      <p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
        The card leans toward the cursor and catches the light.
      </p>
    </TiltCard>
  );
}
```

## Examples

Finished sections built with Tilt Card. Each one installs whole, with the component included.

### Pricing Cards

Three plan cards, with the featured plan leaning harder.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/tilt-card-pricing-demo
```

```tsx title="components/tilt-card-pricing-demo.tsx"
import { TiltCard } from "@/components/ui/tilt-card";

const plans = [
  {
    name: "Hobby",
    price: "$0",
    blurb: "For side projects and experiments.",
    features: ["3 projects", "Community support", "Basic analytics"],
    featured: false,
  },
  {
    name: "Pro",
    price: "$24",
    blurb: "For teams shipping every week.",
    features: ["Unlimited projects", "Priority support", "Advanced analytics"],
    featured: true,
  },
  {
    name: "Team",
    price: "$79",
    blurb: "For organizations that need control.",
    features: ["SSO and audit logs", "Dedicated support", "Custom roles"],
    featured: false,
  },
];

export function TiltCardPricingDemo() {
  return (
    <div className="grid w-full max-w-4xl gap-5 md:grid-cols-3">
      {plans.map((plan) => (
        <TiltCard
          key={plan.name}
          maxTilt={plan.featured ? 14 : 8}
          scale={plan.featured ? 1.05 : 1.02}
          className={
            plan.featured
              ? "border border-zinc-900 bg-zinc-900 p-6 text-zinc-50 shadow-2xl dark:border-zinc-100 dark:bg-zinc-50 dark:text-zinc-900"
              : "border border-zinc-200 bg-white p-6 text-zinc-900 shadow-lg shadow-zinc-900/5 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-50 dark:shadow-black/30"
          }
        >
          <p className="font-mono text-xs uppercase tracking-widest opacity-60">
            {plan.name}
          </p>
          <p className="mt-4 text-4xl font-semibold tracking-tight">
            {plan.price}
            <span className="text-sm font-normal opacity-60"> / month</span>
          </p>
          <p className="mt-2 text-sm opacity-70">{plan.blurb}</p>
          <ul className="mt-6 space-y-2 text-sm">
            {plan.features.map((feature) => (
              <li key={feature} className="flex items-center gap-2">
                <span aria-hidden className="size-1.5 rounded-full bg-current opacity-50" />
                {feature}
              </li>
            ))}
          </ul>
        </TiltCard>
      ))}
    </div>
  );
}
```

### Feature Grid

A grid of subtle tiles with a gentler tilt and no glare.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/tilt-card-feature-grid-demo
```

```tsx title="components/tilt-card-feature-grid-demo.tsx"
import { TiltCard } from "@/components/ui/tilt-card";

const features = [
  { index: "01", title: "Pointer aware", body: "Rotation follows the cursor across the whole card." },
  { index: "02", title: "No re-renders", body: "Position is written to CSS variables, not React state." },
  { index: "03", title: "Reduced motion", body: "Tilt is skipped for people who ask for less movement." },
  { index: "04", title: "Fully themable", body: "Plain Tailwind classes. Bring your own colors." },
];

export function TiltCardFeatureGridDemo() {
  return (
    <div className="w-full max-w-3xl">
      <p className="font-mono text-xs uppercase tracking-widest text-zinc-500">
        Built for interaction
      </p>
      <h3 className="mt-3 max-w-md text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
        One card, four small details
      </h3>
      <div className="mt-8 grid gap-4 sm:grid-cols-2">
        {features.map((feature) => (
          <TiltCard
            key={feature.index}
            glare={false}
            maxTilt={5}
            scale={1.015}
            className="border border-zinc-200 bg-zinc-50 p-5 dark:border-zinc-800 dark:bg-zinc-900/60"
          >
            <p className="font-mono text-xs text-zinc-500">{feature.index}</p>
            <h4 className="mt-6 font-medium text-zinc-900 dark:text-zinc-50">
              {feature.title}
            </h4>
            <p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
              {feature.body}
            </p>
          </TiltCard>
        ))}
      </div>
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` |  | Content rendered inside the card. |
| `className` | `string` |  | Extra classes for the card container. |
| `maxTilt` | `number` | `12` | Maximum rotation in degrees when the pointer is at an edge. |
| `scale` | `number` | `1.03` | Scale applied while the pointer is over the card. |
| `perspective` | `number` | `900` | CSS perspective distance in pixels. Lower values feel more dramatic. |
| `glare` | `boolean` | `true` | Show a light sheen that follows the pointer. |

## Source

```tsx title="components/ui/tilt-card.tsx"
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

export interface TiltCardProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Maximum rotation in degrees when the pointer is at an edge. */
  maxTilt?: number;
  /** Scale applied while the pointer is over the card. */
  scale?: number;
  /** CSS perspective distance in pixels. */
  perspective?: number;
  /** Show a light sheen that follows the pointer. */
  glare?: boolean;
}

export function TiltCard({
  maxTilt = 12,
  scale = 1.03,
  perspective = 900,
  glare = true,
  className,
  style,
  children,
  onPointerEnter,
  onPointerMove,
  onPointerLeave,
  ...props
}: TiltCardProps) {
  const ref = React.useRef<HTMLDivElement>(null);

  // Pointer position is written straight to CSS variables, so moving the
  // pointer never re-renders the card.
  const handlePointerEnter = (event: React.PointerEvent<HTMLDivElement>) => {
    onPointerEnter?.(event);
    ref.current?.style.setProperty("--tilt-scale", String(scale));
    ref.current?.style.setProperty("--glare-opacity", "1");
  };

  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    onPointerMove?.(event);
    const el = ref.current;
    if (!el || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      return;
    }

    const rect = el.getBoundingClientRect();
    const x = (event.clientX - rect.left) / rect.width;
    const y = (event.clientY - rect.top) / rect.height;

    el.style.setProperty("--tilt-x", `${(0.5 - y) * maxTilt * 2}deg`);
    el.style.setProperty("--tilt-y", `${(x - 0.5) * maxTilt * 2}deg`);
    el.style.setProperty("--glare-x", `${x * 100}%`);
    el.style.setProperty("--glare-y", `${y * 100}%`);
  };

  const handlePointerLeave = (event: React.PointerEvent<HTMLDivElement>) => {
    onPointerLeave?.(event);
    const el = ref.current;
    if (!el) return;

    el.style.setProperty("--tilt-x", "0deg");
    el.style.setProperty("--tilt-y", "0deg");
    el.style.setProperty("--tilt-scale", "1");
    el.style.setProperty("--glare-opacity", "0");
  };

  return (
    <div
      ref={ref}
      className={cn(
        "relative rounded-2xl transition-transform duration-200 ease-out will-change-transform",
        className,
      )}
      style={{
        transform: `perspective(${perspective}px) rotateX(var(--tilt-x, 0deg)) rotateY(var(--tilt-y, 0deg)) scale(var(--tilt-scale, 1))`,
        ...style,
      }}
      onPointerEnter={handlePointerEnter}
      onPointerMove={handlePointerMove}
      onPointerLeave={handlePointerLeave}
      {...props}
    >
      {children}
      {glare ? (
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 rounded-[inherit] mix-blend-soft-light transition-opacity duration-200"
          style={{
            opacity: "var(--glare-opacity, 0)",
            background:
              "radial-gradient(circle at var(--glare-x, 50%) var(--glare-y, 50%), rgba(255, 255, 255, 0.55), transparent 55%)",
          }}
        />
      ) : null}
    </div>
  );
}
```
