Skip to content
Uxelith
Sign in
Freev1.0.0Cards

Tilt Card

A card that tilts in 3D toward the pointer and lifts, with parallax layers at their own depth, a shadow that falls away from the raised edge and a soft sheen.

pnpm dlx shadcn add …/r/tilt-card.json
Licence
MIT
Packages
2
Updated
9 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/tilt-card.json
  2. It adds these packages for you:

    clsxtailwind-merge
  3. The source lands in your components folder as tilt-card.tsx. It's yours: change anything.

Usage

example.tsx
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.

tilt-card.tsx
"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

PropTypeDefaultDescription
maxTiltnumber8Largest tilt in degrees, at the card's edges.
glarebooleantrueShow a sheen that follows the pointer.
hoverScalenumber1.02Scale while hovered.
TiltLayer depthnumber10On TiltLayer children: pixels a layer drifts at the edge. Negative sits behind.
...propsHTMLAttributes<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

  1. v1.0.0Latest

    First release.

More in Cards

All 6 in Cards
  • CardsPro

    Card Stack

    A deck you throw cards from: the top card leans from where you grabbed it and flies off with the speed you gave it, the rest rise to take its place, and Undo brings it back. Keys and buttons too.

  • CardsPro

    Compare Slider

    Two layers split by a glass handle you drag, or that follows the pointer. A press glides the divider there on a spring, a double-click sends it home; optional feathered seam, vertical split and first-view sweep.

  • CardsPro

    Morph Card

    A card that grows into its own story: the photo, eyebrow and title fly to their places, and you pull the story down to fold it back into the grid. Built on the native modal dialog.