"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
An interactive 3D holographic badge celebrating Horse Browser's Product Hunt Golden Kitty Award for Maker of the Year 2024. Tilt and shine included.
827 words by Pascal Pixel


