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
+2 -2
View File
@@ -191,9 +191,9 @@ function FilterableTitleRowInner({
containScroll: "trimSnaps", containScroll: "trimSnaps",
}} }}
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]} plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
className="-mx-4 sm:-mx-0" className="-mx-6 sm:-mx-2 carousel-tilt"
> >
<CarouselContent className="px-4 sm:px-0"> <CarouselContent className="px-6 sm:px-2">
{items.slice(0, 20).map((item) => ( {items.slice(0, 20).map((item) => (
<CarouselItem <CarouselItem
key={`${item.type}-${item.tmdbId}`} key={`${item.type}-${item.tmdbId}`}
+2 -2
View File
@@ -64,9 +64,9 @@ export function TitleRow({
<Carousel <Carousel
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }} opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]} plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
className="-mx-4 sm:-mx-0" className="-mx-6 sm:-mx-2 carousel-tilt"
> >
<CarouselContent className="px-4 sm:px-0"> <CarouselContent className="px-6 sm:px-2">
{items.map((item) => ( {items.map((item) => (
<CarouselItem <CarouselItem
key={`${item.type}-${item.tmdbId}`} key={`${item.type}-${item.tmdbId}`}
+6
View File
@@ -142,4 +142,10 @@
.no-scrollbar::-webkit-scrollbar { .no-scrollbar::-webkit-scrollbar {
display: none; display: none;
} }
/* Allow tilt/scale effects to overflow carousel viewport vertically */
.carousel-tilt > [data-slot="carousel-content"] {
overflow-x: clip;
overflow-y: visible;
}
} }
+42 -1
View File
@@ -12,6 +12,7 @@ import {
IconStarFilled, IconStarFilled,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { type MotionStyle, type MotionValue, motion } from "motion/react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
@@ -20,10 +21,17 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useTiltEffect } from "@/hooks/use-tilt-effect";
import { quickAddToWatchlist } from "@/lib/actions/watchlist"; import { quickAddToWatchlist } from "@/lib/actions/watchlist";
type TitleStatus = "watchlist" | "in_progress" | "completed"; type TitleStatus = "watchlist" | "in_progress" | "completed";
interface TiltStyles {
imageStyle: MotionStyle;
glareBackground: MotionValue<string>;
glareOpacity: MotionValue<number>;
}
interface CardInnerProps { interface CardInnerProps {
title: string; title: string;
type: string; type: string;
@@ -32,6 +40,7 @@ interface CardInnerProps {
voteAverage?: number | null; voteAverage?: number | null;
userStatus?: TitleStatus | null; userStatus?: TitleStatus | null;
episodeProgress?: { watched: number; total: number } | null; episodeProgress?: { watched: number; total: number } | null;
tiltStyles?: TiltStyles;
} }
interface TitleCardProps extends CardInnerProps { interface TitleCardProps extends CardInnerProps {
@@ -164,6 +173,7 @@ function CardInner({
voteAverage, voteAverage,
userStatus, userStatus,
episodeProgress, episodeProgress,
tiltStyles,
}: CardInnerProps) { }: CardInnerProps) {
const year = releaseDate?.slice(0, 4); const year = releaseDate?.slice(0, 4);
const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv; const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv;
@@ -174,10 +184,11 @@ function CardInner({
return ( return (
<div <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"> <div className="aspect-[2/3] overflow-hidden bg-card">
{posterPath ? ( {posterPath ? (
<motion.div style={tiltStyles?.imageStyle}>
<Image <Image
src={posterPath} src={posterPath}
alt={title} alt={title}
@@ -185,6 +196,7 @@ function CardInner({
height={450} height={450}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" 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 className="relative flex h-full items-center justify-center overflow-hidden bg-gradient-to-br from-card via-secondary to-muted">
<div <div
@@ -202,6 +214,15 @@ function CardInner({
</div> </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" /> <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>
<div className="px-3 pb-3 pt-2.5"> <div className="px-3 pb-3 pt-2.5">
@@ -262,8 +283,10 @@ export function TitleCard({
userStatus, userStatus,
episodeProgress, episodeProgress,
}: TitleCardProps) { }: TitleCardProps) {
const tilt = useTiltEffect();
return ( return (
<Link href={`/titles/${id}`} className="group"> <Link href={`/titles/${id}`} className="group">
<motion.div ref={tilt.ref} style={tilt.containerStyle} {...tilt.handlers}>
<CardInner <CardInner
title={title} title={title}
type={type} type={type}
@@ -272,7 +295,13 @@ export function TitleCard({
voteAverage={voteAverage} voteAverage={voteAverage}
userStatus={userStatus} userStatus={userStatus}
episodeProgress={episodeProgress} episodeProgress={episodeProgress}
tiltStyles={{
imageStyle: tilt.imageStyle,
glareBackground: tilt.glareBackground,
glareOpacity: tilt.glareOpacity,
}}
/> />
</motion.div>
</Link> </Link>
); );
} }
@@ -289,6 +318,7 @@ export function ExploreTitleCard({
userStatus, userStatus,
episodeProgress, episodeProgress,
}: ExploreTitleCardProps) { }: ExploreTitleCardProps) {
const tilt = useTiltEffect();
return ( return (
<div className="relative group"> <div className="relative group">
<QuickAddButton <QuickAddButton
@@ -297,6 +327,11 @@ export function ExploreTitleCard({
userStatus={userStatus} userStatus={userStatus}
/> />
<Link href={href}> <Link href={href}>
<motion.div
ref={tilt.ref}
style={tilt.containerStyle}
{...tilt.handlers}
>
<CardInner <CardInner
title={title} title={title}
type={type} type={type}
@@ -305,7 +340,13 @@ export function ExploreTitleCard({
voteAverage={voteAverage} voteAverage={voteAverage}
userStatus={userStatus} userStatus={userStatus}
episodeProgress={episodeProgress} episodeProgress={episodeProgress}
tiltStyles={{
imageStyle: tilt.imageStyle,
glareBackground: tilt.glareBackground,
glareOpacity: tilt.glareOpacity,
}}
/> />
</motion.div>
</Link> </Link>
</div> </div>
); );
+126
View File
@@ -0,0 +1,126 @@
"use client";
import {
type MotionStyle,
useMotionTemplate,
useMotionValue,
useSpring,
useTransform,
} from "motion/react";
import { useEffect, useRef, useState } from "react";
interface TiltConfig {
maxTilt?: number;
perspective?: number;
scale?: number;
glareMaxOpacity?: number;
parallaxShift?: number;
spring?: { stiffness: number; damping: number; mass: number };
}
const defaults = {
maxTilt: 8,
perspective: 800,
scale: 1.02,
glareMaxOpacity: 0.15,
parallaxShift: 5,
spring: { stiffness: 300, damping: 30, mass: 0.5 },
} satisfies Required<TiltConfig>;
export function useTiltEffect(config: TiltConfig = {}) {
const opts = { ...defaults, ...config };
const ref = useRef<HTMLDivElement>(null);
const rectRef = useRef<DOMRect | null>(null);
const [disabled, setDisabled] = useState(false);
useEffect(() => {
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
setDisabled(mql.matches);
const handler = (e: MediaQueryListEvent) => setDisabled(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, []);
// Normalized mouse position [0,1], center = 0.5
const mouseX = useMotionValue(0.5);
const mouseY = useMotionValue(0.5);
const isHovered = useMotionValue(0);
// Rotation (mouse left -> tilt right for natural feel)
const rawRotateY = useTransform(
mouseX,
[0, 1],
[opts.maxTilt, -opts.maxTilt],
);
const rawRotateX = useTransform(
mouseY,
[0, 1],
[-opts.maxTilt, opts.maxTilt],
);
const rawScale = useTransform(isHovered, [0, 1], [1, opts.scale]);
// Springs
const rotateX = useSpring(rawRotateX, opts.spring);
const rotateY = useSpring(rawRotateY, opts.spring);
const scale = useSpring(rawScale, opts.spring);
const hoverSpring = useSpring(isHovered, opts.spring);
// Parallax image offset (opposite direction)
const imageX = useSpring(
useTransform(mouseX, [0, 1], [opts.parallaxShift, -opts.parallaxShift]),
opts.spring,
);
const imageY = useSpring(
useTransform(mouseY, [0, 1], [opts.parallaxShift, -opts.parallaxShift]),
opts.spring,
);
// Glare
const glareX = useTransform(mouseX, [0, 1], [0, 100]);
const glareY = useTransform(mouseY, [0, 1], [0, 100]);
const glareOpacity = useTransform(
hoverSpring,
[0, 1],
[0, opts.glareMaxOpacity],
);
const glareBackground = useMotionTemplate`radial-gradient(circle at ${glareX}% ${glareY}%, rgba(255,255,255,0.25) 0%, transparent 60%)`;
function onMouseMove(e: React.MouseEvent) {
if (disabled || !rectRef.current) return;
const rect = rectRef.current;
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
mouseX.set(Math.max(0, Math.min(1, x)));
mouseY.set(Math.max(0, Math.min(1, y)));
}
function onMouseEnter() {
if (disabled) return;
rectRef.current = ref.current?.getBoundingClientRect() ?? null;
isHovered.set(1);
}
function onMouseLeave() {
if (disabled) return;
mouseX.set(0.5);
mouseY.set(0.5);
isHovered.set(0);
rectRef.current = null;
}
const containerStyle: MotionStyle = disabled
? {}
: { transformPerspective: opts.perspective, rotateX, rotateY, scale };
const imageStyle: MotionStyle = disabled ? {} : { x: imageX, y: imageY };
return {
ref,
containerStyle,
imageStyle,
glareBackground,
glareOpacity,
handlers: { onMouseMove, onMouseEnter, onMouseLeave },
disabled,
};
}