From 9c6da8db53573fef77854a4e8cfdb369cc410747 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Thu, 5 Mar 2026 16:00:50 -0500 Subject: [PATCH] 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 --- app/(pages)/explore/filterable-title-row.tsx | 4 +- app/(pages)/explore/title-row.tsx | 4 +- app/globals.css | 6 + components/title-card.tsx | 93 ++++++++++---- hooks/use-tilt-effect.ts | 126 +++++++++++++++++++ 5 files changed, 203 insertions(+), 30 deletions(-) create mode 100644 hooks/use-tilt-effect.ts diff --git a/app/(pages)/explore/filterable-title-row.tsx b/app/(pages)/explore/filterable-title-row.tsx index 93a508b..3383d83 100644 --- a/app/(pages)/explore/filterable-title-row.tsx +++ b/app/(pages)/explore/filterable-title-row.tsx @@ -191,9 +191,9 @@ function FilterableTitleRowInner({ containScroll: "trimSnaps", }} plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]} - className="-mx-4 sm:-mx-0" + className="-mx-6 sm:-mx-2 carousel-tilt" > - + {items.slice(0, 20).map((item) => ( - + {items.map((item) => ( [data-slot="carousel-content"] { + overflow-x: clip; + overflow-y: visible; + } } diff --git a/components/title-card.tsx b/components/title-card.tsx index 503c12d..429153b 100644 --- a/components/title-card.tsx +++ b/components/title-card.tsx @@ -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; + glareOpacity: MotionValue; +} + 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 (
{posterPath ? ( - {title} + + {title} + ) : (
)}
+ {tiltStyles && ( + + )}
@@ -262,17 +283,25 @@ export function TitleCard({ userStatus, episodeProgress, }: TitleCardProps) { + const tilt = useTiltEffect(); return ( - + + + ); } @@ -289,6 +318,7 @@ export function ExploreTitleCard({ userStatus, episodeProgress, }: ExploreTitleCardProps) { + const tilt = useTiltEffect(); return (
- + + +
); diff --git a/hooks/use-tilt-effect.ts b/hooks/use-tilt-effect.ts new file mode 100644 index 0000000..41fc74e --- /dev/null +++ b/hooks/use-tilt-effect.ts @@ -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; + +export function useTiltEffect(config: TiltConfig = {}) { + const opts = { ...defaults, ...config }; + const ref = useRef(null); + const rectRef = useRef(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, + }; +}