Add 3D parallax tilt effect to title cards on hover

Spring-animated 3D tilt, parallax image shift, and glare reflection
follow the cursor using motion values for zero-rerender performance.
Respects prefers-reduced-motion. Carousel overflow adjusted to prevent
clipping of the tilt effect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 16:00:50 -05:00
co-authored by Claude Opus 4.6
parent 71b39d5aca
commit 9c6da8db53
5 changed files with 203 additions and 30 deletions
+67 -26
View File
@@ -12,6 +12,7 @@ import {
IconStarFilled,
} from "@tabler/icons-react";
import { type MotionStyle, type MotionValue, motion } from "motion/react";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
@@ -20,10 +21,17 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useTiltEffect } from "@/hooks/use-tilt-effect";
import { quickAddToWatchlist } from "@/lib/actions/watchlist";
type TitleStatus = "watchlist" | "in_progress" | "completed";
interface TiltStyles {
imageStyle: MotionStyle;
glareBackground: MotionValue<string>;
glareOpacity: MotionValue<number>;
}
interface CardInnerProps {
title: string;
type: string;
@@ -32,6 +40,7 @@ interface CardInnerProps {
voteAverage?: number | null;
userStatus?: TitleStatus | null;
episodeProgress?: { watched: number; total: number } | null;
tiltStyles?: TiltStyles;
}
interface TitleCardProps extends CardInnerProps {
@@ -164,6 +173,7 @@ function CardInner({
voteAverage,
userStatus,
episodeProgress,
tiltStyles,
}: CardInnerProps) {
const year = releaseDate?.slice(0, 4);
const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv;
@@ -174,17 +184,19 @@ function CardInner({
return (
<div
className={`relative overflow-hidden rounded-xl bg-card ring-1 transition-all duration-200 ease-out hover:scale-[1.02] hover:ring-primary/25 hover:shadow-lg hover:shadow-primary/5 ${ringClass}`}
className={`relative overflow-hidden rounded-xl bg-card ring-1 transition-[box-shadow,ring-color] duration-200 ease-out hover:ring-primary/25 hover:shadow-lg hover:shadow-primary/5 ${ringClass}`}
>
<div className="aspect-[2/3] overflow-hidden bg-card">
{posterPath ? (
<Image
src={posterPath}
alt={title}
width={300}
height={450}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
<motion.div style={tiltStyles?.imageStyle}>
<Image
src={posterPath}
alt={title}
width={300}
height={450}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</motion.div>
) : (
<div className="relative flex h-full items-center justify-center overflow-hidden bg-gradient-to-br from-card via-secondary to-muted">
<div
@@ -202,6 +214,15 @@ function CardInner({
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
{tiltStyles && (
<motion.div
className="pointer-events-none absolute inset-0 z-[5] rounded-xl"
style={{
background: tiltStyles.glareBackground,
opacity: tiltStyles.glareOpacity,
}}
/>
)}
</div>
<div className="px-3 pb-3 pt-2.5">
@@ -262,17 +283,25 @@ export function TitleCard({
userStatus,
episodeProgress,
}: TitleCardProps) {
const tilt = useTiltEffect();
return (
<Link href={`/titles/${id}`} className="group">
<CardInner
title={title}
type={type}
posterPath={posterPath}
releaseDate={releaseDate}
voteAverage={voteAverage}
userStatus={userStatus}
episodeProgress={episodeProgress}
/>
<motion.div ref={tilt.ref} style={tilt.containerStyle} {...tilt.handlers}>
<CardInner
title={title}
type={type}
posterPath={posterPath}
releaseDate={releaseDate}
voteAverage={voteAverage}
userStatus={userStatus}
episodeProgress={episodeProgress}
tiltStyles={{
imageStyle: tilt.imageStyle,
glareBackground: tilt.glareBackground,
glareOpacity: tilt.glareOpacity,
}}
/>
</motion.div>
</Link>
);
}
@@ -289,6 +318,7 @@ export function ExploreTitleCard({
userStatus,
episodeProgress,
}: ExploreTitleCardProps) {
const tilt = useTiltEffect();
return (
<div className="relative group">
<QuickAddButton
@@ -297,15 +327,26 @@ export function ExploreTitleCard({
userStatus={userStatus}
/>
<Link href={href}>
<CardInner
title={title}
type={type}
posterPath={posterPath}
releaseDate={releaseDate}
voteAverage={voteAverage}
userStatus={userStatus}
episodeProgress={episodeProgress}
/>
<motion.div
ref={tilt.ref}
style={tilt.containerStyle}
{...tilt.handlers}
>
<CardInner
title={title}
type={type}
posterPath={posterPath}
releaseDate={releaseDate}
voteAverage={voteAverage}
userStatus={userStatus}
episodeProgress={episodeProgress}
tiltStyles={{
imageStyle: tilt.imageStyle,
glareBackground: tilt.glareBackground,
glareOpacity: tilt.glareOpacity,
}}
/>
</motion.div>
</Link>
</div>
);