Product Hunt gold foil / holographic badge

December 17th, 2024

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

"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>
  );
}

Get on the Horse

The browser built for ADHD minds. Trails® keep the task alive when a tangent or interruption carries you elsewhere.

favicon
Japanese Green TeasGoogle Search
favicon
Japanese Green TeaWikipedia
favicon
MatchaWikipedia
favicon
SenchaWikipedia
Sencha

From Wikipedia, the free encyclopedia

Sencha tea leaves and brewed tea

Sencha tea leaves and brewed tea

Sencha (煎茶) is a type of Japanese ryokucha (緑茶, green tea) which is prepared by infusing the processed whole tea leaves in hot water. This is as opposed to matcha (抹茶), powdered Japanese green tea, where the green tea powder is mixed with hot water and therefore the leaf itself is included in the beverage. Sencha is the most popular tea in Japan.
Types of sencha

The types of sencha are distinguished by when they are harvested. Shincha(新茶, "new tea") represents the first month's harvest of sencha. Basically, it's the same as ichibancha(一番茶, "first tea"), which is the first harvest of the year.

Kabusecha (かぶせ茶) is sencha grown in the shade for about a week before harvest. Asamushi (浅蒸し) is lightly steamed sencha, while fukamushi (深蒸し) is deeply steamed sencha.

Production

Sencha tea is made from the leaves of the Camellia sinensis plant. The leaves are steamed, rolled, and dried immediately after harvest to prevent oxidation. This process preserves the fresh, grassy flavor that sencha is known for.

The steaming process used in making sencha is what differentiates it from Chinese green teas, which are typically pan-fired. The duration of the steaming process affects the final taste and color of the tea.

Brewing

Sencha is typically brewed at lower temperatures than black tea or oolong tea. The ideal water temperature is usually between 60–80°C (140–176°F), with brewing time ranging from 1 to 2 minutes.

The tea can be brewed multiple times, with each infusion revealing different flavor notes. The first brew tends to be more astringent and fresh, while subsequent brews become milder and sweeter.

Get the Good Horse Email

One useful feature, one good story, or one meaningful update. We only send when we have something worth opening.

Unsubscribe anytime.
Horse NewsletterIssue #12

Your browser can remember the internet for you.

Drag a Trail into your notes and the whole branching path lands as Markdown. Yes, the entire tiny family tree. Computers love carrying things!

Read the Manual

Every button, shortcut, Trail, folder, note, and slightly secret Horse trick, explained plainly.

Open the Manual
Opening page of the Horse Browser manual

Need Help?

Find the answer, manage your license or billing, and get Horse back onto the Trail.

Get Help
Pascal and Eleanor at Disneysea Tokyo