mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
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:
+67
-26
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user