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:
Alex Gleason
2026-03-25 22:50:50 -05:00
parent 0e46804e25
commit fe2fa7cfce
2 changed files with 168 additions and 34 deletions
+102 -34
View File
@@ -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">
+66
View File
@@ -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;
}