Mikkel Manniche
Dansk English Deutsch

Surfaces

Tilt card

A card that tilts towards the mouse in 3D, with layers that float above it. Flat on touch screens and under reduced motion.

Preview

Usage

tilt-card-demo.tsx
import { TiltCard } from '@/components/tilt-card'

export default function TiltCardDemo() {
  return (
    <TiltCard className="mx-auto max-w-sm">
      <div data-depth="1" className="h-36 rounded-xl bg-[linear-gradient(135deg,#f97316,#db2777_50%,#4f46e5)]" />
      <p data-depth="2" className="mt-4 font-medium">
        New website in two weeks
      </p>
      <p data-depth="1" className="mt-1 text-sm text-muted-foreground">
        Move the mouse over the card.
      </p>
    </TiltCard>
  )
}

Source

tilt-card.tsx
import { useRef, type CSSProperties, type HTMLAttributes, type PointerEvent } from 'react'
import { cn } from '@/lib/utils'

export type TiltCardProps = HTMLAttributes<HTMLDivElement> & {
  /** The largest tilt in degrees, reached at the card's edges. */
  max?: number
  /** A soft glare that follows the pointer. */
  glare?: boolean
}

/**
 * A card that tilts towards the mouse in 3D. Children with `data-depth="1"` to `"3"` float above the card.
 * The angles are written straight to CSS variables, so React does not re-render on move.
 * On touch screens and under reduced motion it is a flat card.
 */
export function TiltCard({ max = 10, glare = true, className, children, onPointerMove, onPointerLeave, style, ...rest }: TiltCardProps) {
  const el = useRef<HTMLDivElement>(null)

  function move(e: PointerEvent<HTMLDivElement>) {
    onPointerMove?.(e)
    if (e.pointerType !== 'mouse' || !el.current) return
    const r = el.current.getBoundingClientRect()
    const x = (e.clientX - r.left) / r.width
    const y = (e.clientY - r.top) / r.height
    el.current.style.setProperty('--tilt-x', `${(0.5 - y) * 2 * max}deg`)
    el.current.style.setProperty('--tilt-y', `${(x - 0.5) * 2 * max}deg`)
    el.current.style.setProperty('--glare-x', `${x * 100}%`)
    el.current.style.setProperty('--glare-y', `${y * 100}%`)
  }

  function leave(e: PointerEvent<HTMLDivElement>) {
    onPointerLeave?.(e)
    el.current?.style.setProperty('--tilt-x', '0deg')
    el.current?.style.setProperty('--tilt-y', '0deg')
  }

  return (
    <div className="[perspective:900px]">
      <div
        ref={el}
        onPointerMove={move}
        onPointerLeave={leave}
        style={style as CSSProperties}
        className={cn(
          'group/tilt relative isolate rounded-2xl border bg-card p-6 text-card-foreground shadow-sm [transform-style:preserve-3d]',
          '[transform:rotateX(var(--tilt-x,0deg))_rotateY(var(--tilt-y,0deg))] transition-transform duration-200 ease-out',
          // The depth layers lift off the card only while it can tilt.
          'motion-safe:[&_[data-depth="1"]]:[transform:translateZ(20px)] motion-safe:[&_[data-depth="2"]]:[transform:translateZ(40px)] motion-safe:[&_[data-depth="3"]]:[transform:translateZ(60px)]',
          'motion-reduce:transform-none motion-reduce:transition-none',
          className,
        )}
        {...rest}
      >
        {glare && (
          <span
            aria-hidden
            className={cn(
              'pointer-events-none absolute inset-0 -z-10 rounded-[inherit] opacity-0 transition-opacity duration-300',
              'bg-[radial-gradient(circle_at_var(--glare-x,50%)_var(--glare-y,50%),rgb(255_255_255/0.14),transparent_60%)]',
              'group-hover/tilt:opacity-100 motion-reduce:hidden',
            )}
          />
        )}
        {children}
      </div>
    </div>
  )
}

Install

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/tilt-card.json

With the registry added (components.json)

npx shadcn@latest add @manniche/tilt-card

Dependencies

Files

  • tilt-card.tsx

Manniche Lab · open source

Free tools for AI agents and web development.