"use client";
import { useState, useEffect, useRef, memo, useMemo } from "react";
interface BadgeProps {
url?: string;
width?: number;
height?: number;
textColor?: string;
borderColor?: string;
bgColor?: string;
maxRotationX?: number;
maxRotationY?: number;
title?: string;
subtitle?: string;
}
type Point = {
x: number;
y: number;
};
const ANIMATION_CONFIG = {
SPRING_STIFFNESS: 200,
SPRING_DAMPING: 10,
IDLE_ANIMATION_DURATION: 8,
MAX_DELTA_TIME: 0.016,
} as const;
const RAINBOW_COLORS = [
"hsl(358, 100%, 62%)",
"hsl(30, 100%, 50%)",
"hsl(60, 100%, 50%)",
"hsl(96, 100%, 50%)",
"hsl(233, 85%, 47%)",
"hsl(271, 85%, 47%)",
"hsl(300, 20%, 35%)",
"transparent",
"transparent",
"white",
] as const;
const createMatrix = ({ x, y }: Point) => ({
scaleX: Math.cos((y * Math.PI) / 180),
shearY1: 0,
rotationY: Math.sin((y * Math.PI) / 180),
perspective1: 0,
rotationCompoundXY:
Math.sin((x * Math.PI) / 180) * Math.sin((y * Math.PI) / 180),
scaleY: Math.cos((x * Math.PI) / 180),
rotationCompound:
-Math.sin((x * Math.PI) / 180) * Math.cos((y * Math.PI) / 180),
perspective2: 0,
rotationX: -Math.sin((y * Math.PI) / 180),
rotationZ: Math.sin((x * Math.PI) / 180),
scaleZ: Math.cos((x * Math.PI) / 180) * Math.cos((y * Math.PI) / 180),
perspective3: 0,
translateX: 0,
translateY: 0,
translateZ: 0,
perspectiveFactor: 1,
});
function useAnimation(target: Point, isHovering: boolean) {
const speed = useRef<Point>({ x: 0, y: 0 });
const [rotation, setRotation] = useState<Point>({ x: 0, y: 0 });
const frameRef = useRef<number>(null);
const lastTimeRef = useRef(performance.now());
useEffect(() => {
function animate() {
const now = performance.now();
const delta = Math.min(
(now - lastTimeRef.current) / 1000,
ANIMATION_CONFIG.MAX_DELTA_TIME,
);
lastTimeRef.current = now;
// Calculate the current target based on interaction state
const currentTarget = isHovering
? target
: {
// Create a figure-8 motion path for idle state
x: Math.sin((now / 4000) * Math.PI),
y: Math.sin((now / 2000) * Math.PI),
};
// Spring physics
const springForceX =
(currentTarget.x - rotation.x) * ANIMATION_CONFIG.SPRING_STIFFNESS;
const springForceY =
(currentTarget.y - rotation.y) * ANIMATION_CONFIG.SPRING_STIFFNESS;
const dampingForceX = -speed.current.x * ANIMATION_CONFIG.SPRING_DAMPING;
const dampingForceY = -speed.current.y * ANIMATION_CONFIG.SPRING_DAMPING;
// Update speed with spring and damping forces
speed.current = {
x: speed.current.x + (springForceX + dampingForceX) * delta,
y: speed.current.y + (springForceY + dampingForceY) * delta,
};
// Update position
setRotation((prev) => ({
x: prev.x + speed.current.x * delta,
y: prev.y + speed.current.y * delta,
}));
frameRef.current = requestAnimationFrame(animate);
}
frameRef.current = requestAnimationFrame(animate);
return () => {
if (frameRef.current) {
cancelAnimationFrame(frameRef.current);
}
};
}, [target, rotation, isHovering]);
return rotation;
}
function AwardLogo({ color }: { color: string }) {
return (
<g transform="translate(8, 9)">
<path
fill={color}
d="M14.963 9.075c.787-3-.188-5.887-.188-5.887S12.488 5.175 11.7 8.175c-.787 3 .188 5.887.188 5.887s2.25-1.987 3.075-4.987m-4.5 1.987c.787 3-.188 5.888-.188 5.888S7.988 14.962 7.2 11.962c-.787-3 .188-5.887.188-5.887s2.287 1.987 3.075 4.987m.862 10.388s-.6-2.962-2.775-5.175C6.337 14.1 3.375 13.5 3.375 13.5s.6 2.962 2.775 5.175c2.213 2.175 5.175 2.775 5.175 2.775m3.3 3.413s-1.988-2.288-4.988-3.075-5.887.187-5.887.187 1.987 2.287 4.988 3.075c3 .787 5.887-.188 5.887-.188Zm6.75 0s1.988-2.288 4.988-3.075c3-.826 5.887.187 5.887.187s-1.988 2.287-4.988 3.075c-3 .787-5.887-.188-5.887-.188ZM32.625 13.5s-2.963.6-5.175 2.775c-2.213 2.213-2.775 5.175-2.775 5.175s2.962-.6 5.175-2.775c2.175-2.213 2.775-5.175 2.775-5.175M28.65 6.075s.975 2.887.188 5.887c-.826 3-3.076 4.988-3.076 4.988s-.974-2.888-.187-5.888c.788-3 3.075-4.987 3.075-4.987m-4.5 7.987s.975-2.887.188-5.887c-.788-3-3.076-4.988-3.076-4.988s-.974 2.888-.187 5.888c.788 3 3.075 4.988 3.075 4.988ZM18 26.1c.975-.225 3.113-.6 5.325 0 3 .788 5.063 3.038 5.063 3.038s-2.888.975-5.888.187a13 13 0 0 1-1.425-.525c.563.788 1.125 1.425 2.288 1.913l-.863 2.062c-2.063-.862-2.925-2.137-3.675-3.262-.262-.375-.525-.713-.787-1.05-.26.293-.465.586-.686.903l-.102.147-.048.068c-.775 1.108-1.643 2.35-3.627 3.194l-.862-2.062c1.162-.488 1.725-1.125 2.287-1.913-.45.225-.938.375-1.425.525-3 .788-5.887-.187-5.887-.187s1.987-2.288 4.987-3.075c2.212-.563 4.35-.188 5.325.037"
/>
</g>
);
}
function ShinyEffect({
width,
height,
shineAngle,
}: {
width: number;
height: number;
shineAngle: number;
}) {
return (
<g style={{ mixBlendMode: "overlay" }} mask="url(#badgeMask)">
{RAINBOW_COLORS.map((color, i) => (
<g
key={i}
style={{
transform: `rotate(${shineAngle * 3.2 + i * 10 - 20}deg)`,
transformOrigin: "center center",
}}
>
<polygon
points={`0,0 ${width},${height} ${width},0 0,${height}`}
fill={color}
filter="url(#blur1)"
opacity="0.5"
/>
</g>
))}
</g>
);
}
export default function GoldenKittyBadge({
url = "https://www.producthunt.com/golden-kitty-awards/hall-of-fame?year=2024#maker-of-the-year-10",
width = 260,
height = 54,
bgColor = "hsl(54, 100%, 49%)",
borderColor = "hsl(54, 100%, 45%)",
textColor = "hsl(40, 100%, 30%)",
maxRotationX = 15,
maxRotationY = 15,
title = "PRODUCT HUNT",
subtitle = "Maker of the Year 2024",
}: BadgeProps) {
const [isHovering, setIsHovering] = useState(false);
const [target, setTarget] = useState<Point>({ x: 0, y: 0 });
const rotation = useAnimation(target, isHovering);
const matrix = useMemo(() => createMatrix(rotation), [rotation]);
const shineAngle = useMemo(
() => Math.hypot(rotation.x, rotation.y) * 2,
[rotation.x, rotation.y],
);
const handleMouseMove = useMemo(
() => (e: React.MouseEvent<HTMLAnchorElement>) => {
if (!isHovering) return;
const rect = e.currentTarget.getBoundingClientRect();
setTarget({
x: ((e.clientY - rect.top) / rect.height - 0.5) * 2 * maxRotationX,
y: -((e.clientX - rect.left) / rect.width - 0.5) * 2 * maxRotationY,
});
},
[isHovering, maxRotationX, maxRotationY],
);
const handleMouseLeave = useMemo(
() => () => {
setIsHovering(false);
setTarget({ x: 0, y: 0 });
},
[],
);
return (
<a
href={url}
target="_blank"
rel="noopener"
className="block relative"
onMouseMove={handleMouseMove}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={handleMouseLeave}
>
<div
style={{
transform: `perspective(500px) matrix3d(${Object.values(matrix).join(
",",
)})`,
transformOrigin: "center center",
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={`0 0 ${width} ${height}`}
className="w-[180px] sm:w-[260px] h-auto"
>
<defs>
<filter id="blur1">
<feGaussianBlur in="SourceGraphic" stdDeviation="3" />
</filter>
<mask id="badgeMask">
<rect width={width} height={height} fill="white" rx="10" />
</mask>
</defs>
<rect width={width} height={height} rx="10" fill={bgColor} />
<rect
x="4"
y="4"
width={width - 8}
height={height - 8}
rx="8"
fill="transparent"
stroke={borderColor}
strokeWidth="1"
/>
<text
fontFamily="Helvetica-Bold, Helvetica"
fontSize="9"
fontWeight="bold"
fill={textColor}
x="53"
y="20"
>
{title}
</text>
<text
fontFamily="Helvetica-Bold, Helvetica"
fontSize="16"
fontWeight="bold"
fill={textColor}
x="52"
y="40"
>
{subtitle}
</text>
<AwardLogo color={textColor} />
<ShinyEffect width={width} height={height} shineAngle={shineAngle} />
</svg>
</div>
</a>
);
}
Product Hunt Gold Foil / Holographic Badge
February 28th, 2025
827 words by Pascal Pixel


