Add interactive 3D tilt effect to badge detail hero image
Hovering the mouse across the badge image now rotates it in perspective space with a specular glare overlay that tracks the cursor, making the badge feel like a tangible, glossy object. Introduces a reusable useCardTilt hook for pointer-driven 3D perspective transforms with configurable max tilt and scale.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Award, Copy, Check, Users, Gift, Loader2, MessageCircle, Newspaper, MoreHorizontal } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
@@ -33,6 +33,7 @@ import { genUserName } from '@/lib/genUserName';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
import { parseBadgeDefinition } from '@/components/BadgeContent';
|
||||
import { useCardTilt } from '@/hooks/useCardTilt';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { AwardBadgeDialog } from '@/components/AwardBadgeDialog';
|
||||
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
|
||||
@@ -146,40 +147,9 @@ export function BadgeDetailContent({ event }: { event: NostrEvent }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero badge image */}
|
||||
{/* Hero badge image with 3D tilt */}
|
||||
{heroImage ? (
|
||||
<div className="relative isolate flex justify-center py-10 overflow-hidden">
|
||||
{/* Rotating light rays */}
|
||||
<div
|
||||
className="absolute -z-10 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 420,
|
||||
height: 420,
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full animate-badge-spotlight"
|
||||
style={{
|
||||
background: `repeating-conic-gradient(
|
||||
hsl(var(--primary) / 0.08) 0deg 6deg,
|
||||
transparent 6deg 18deg
|
||||
)`,
|
||||
maskImage: 'radial-gradient(circle, black 15%, transparent 70%)',
|
||||
WebkitMaskImage: 'radial-gradient(circle, black 15%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
src={heroImage}
|
||||
alt={badge.name}
|
||||
className="size-36 rounded-2xl object-cover drop-shadow-lg relative z-[1]"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<BadgeHero heroImage={heroImage} badgeName={badge.name} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-[180px]">
|
||||
<Award className="size-16 text-primary/20" />
|
||||
@@ -575,6 +545,104 @@ function AwardeeCardSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge hero with interactive 3D tilt. Hovering moves the badge in
|
||||
* perspective space while a specular glare overlay tracks the pointer,
|
||||
* making the badge feel like a tangible, glossy object.
|
||||
*/
|
||||
function BadgeHero({ heroImage, badgeName }: { heroImage: string; badgeName: string }) {
|
||||
const tilt = useCardTilt(18, 1.06);
|
||||
const glareRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
tilt.onPointerMove(e);
|
||||
|
||||
// Move the specular glare to follow the cursor
|
||||
const el = tilt.ref.current;
|
||||
const glare = glareRef.current;
|
||||
if (!el || !glare) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
glare.style.background = `radial-gradient(circle at ${x}% ${y}%, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0.08) 35%, transparent 65%)`;
|
||||
glare.style.opacity = '1';
|
||||
},
|
||||
[tilt],
|
||||
);
|
||||
|
||||
const handlePointerLeave = useCallback(
|
||||
() => {
|
||||
tilt.onPointerLeave();
|
||||
const glare = glareRef.current;
|
||||
if (glare) glare.style.opacity = '0';
|
||||
},
|
||||
[tilt],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative isolate flex justify-center py-10 overflow-hidden">
|
||||
{/* Rotating light rays (behind tilt container) */}
|
||||
<div
|
||||
className="absolute -z-10 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 420,
|
||||
height: 420,
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full animate-badge-spotlight"
|
||||
style={{
|
||||
background: `repeating-conic-gradient(
|
||||
hsl(var(--primary) / 0.08) 0deg 6deg,
|
||||
transparent 6deg 18deg
|
||||
)`,
|
||||
maskImage: 'radial-gradient(circle, black 15%, transparent 70%)',
|
||||
WebkitMaskImage: 'radial-gradient(circle, black 15%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 3D-tiltable badge */}
|
||||
<div
|
||||
ref={tilt.ref}
|
||||
style={{ ...tilt.style, transformStyle: 'preserve-3d' }}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
className="relative cursor-grab active:cursor-grabbing select-none"
|
||||
>
|
||||
<img
|
||||
src={heroImage}
|
||||
alt={badgeName}
|
||||
className="size-36 rounded-2xl object-cover drop-shadow-lg"
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
/>
|
||||
{/* Specular glare overlay */}
|
||||
<div
|
||||
ref={glareRef}
|
||||
className="absolute inset-0 rounded-2xl pointer-events-none"
|
||||
style={{
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.4s ease-out',
|
||||
mixBlendMode: 'overlay',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Edge shadow for depth */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-2xl pointer-events-none ring-1 ring-black/10 dark:ring-white/10"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentsSkeleton() {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
interface TiltState {
|
||||
rotateX: number;
|
||||
rotateY: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
const INITIAL: TiltState = { rotateX: 0, rotateY: 0, scale: 1 };
|
||||
|
||||
/**
|
||||
* Provides a 3D perspective-tilt effect driven by the mouse position
|
||||
* relative to the element. Returns a ref to attach to the container,
|
||||
* a style object for the `transform`, and pointer event handlers.
|
||||
*
|
||||
* @param maxTilt Maximum rotation in degrees (default 20)
|
||||
* @param scaleFactor Scale multiplier on hover (default 1.05)
|
||||
*/
|
||||
export function useCardTilt(maxTilt = 20, scaleFactor = 1.05) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [tilt, setTilt] = useState<TiltState>(INITIAL);
|
||||
const frameRef = useRef<number>(0);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
// Throttle to one update per animation frame
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
// Normalise to -1 … 1 from centre
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 2 - 1;
|
||||
|
||||
setTilt({
|
||||
// Positive Y-mouse → negative rotateX (tilts top away)
|
||||
rotateX: -y * maxTilt,
|
||||
// Positive X-mouse → positive rotateY (tilts right side away)
|
||||
rotateY: x * maxTilt,
|
||||
scale: scaleFactor,
|
||||
});
|
||||
});
|
||||
},
|
||||
[maxTilt, scaleFactor],
|
||||
);
|
||||
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
setTilt(INITIAL);
|
||||
}, []);
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: `perspective(600px) rotateX(${tilt.rotateX}deg) rotateY(${tilt.rotateY}deg) scale3d(${tilt.scale}, ${tilt.scale}, ${tilt.scale})`,
|
||||
transition: tilt.scale === 1 ? 'transform 0.5s cubic-bezier(0.22, 1, 0.36, 1)' : 'transform 0.1s ease-out',
|
||||
willChange: 'transform',
|
||||
};
|
||||
|
||||
return {
|
||||
ref,
|
||||
style,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerLeave: handlePointerLeave,
|
||||
} as const;
|
||||
}
|
||||
Reference in New Issue
Block a user