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:
@@ -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}`}
|
||||||
|
|||||||
@@ -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}`}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user