Skip to content
Components

Tilt Card

A bounded spring tilt with stable pointer tracking and reduced-motion support.

Open ↗

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.json

Use 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
{
  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,
}: {
  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
      style={{ perspective: 1000 }}
      onPointerMove={(e) => {
        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={reset}
      onPointerCancel={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 →