How I made the batcat brand logo
4 min read

This is more of a code and design showcase, and less of a tutorial. A few people asked how I made the batcat logo, so here it is.

The goal was to create an M for my brand logo, and playing around with shapes in figma, batcat was born.

Batcat

I export the svg as a frame with individual shapes / paths so they could be animated individually.

<svg width="180" height="180" viewBox="0 0 180 180" fill="none">
  <circle cx="90" cy="90" r="90" fill="white"/>
  <path d="M163 180L120 34L53 180H163Z" fill="black" fill-opacity="0.5"/>
  <path d="M17 180L60 34L127 180H17Z" fill="black" fill-opacity="0.5"/>
  <circle cx="90.5" cy="111.5" r="37.5" fill="black" fill-opacity="0.5"/>
  <ellipse cx="103.5" cy="112" rx="7.5" ry="15" fill="white"/>
  <ellipse cx="77.5" cy="112" rx="7.5" ry="15" fill="white"/>
</svg>

Then I created a react component rendering this svg inline, and using mouse and touch event handlers to track movements of the cursor or finger on the screen.

The ear shapes shift path slightly depending on the cursor on the x axis.

The head and eyes shift on both axis and at different speed and offset to create a 3d effect.

Finally, I added a blinking animation at random intervals.

The full code should be pretty self explanatory.

batcat.tsx
import { useState, useEffect, useCallback, useRef } from "react";
import { cn } from "@lib/utils";

export default function BatCat() {
  const blinkRef = useRef<null | NodeJS.Timeout>(null);
  const [isBlinking, setIsBlinking] = useState(false);
  const [positions, setPositions] = useState({
    head: {
      x: 90,
      y: 110,
    },
    leftEye: {
      x: 78,
      y: 112
    },
    rightEye: {
      x: 104,
      y: 112
    },
    leftEar: {
      x: 60,
    },
    rightEar: {
      x: 120,
    },
  });

  const handleMove = useCallback((clientX: number, clientY: number) => {
    const svgElement = document.getElementById("brand");
    if (!svgElement) return;
    const rect = svgElement.getBoundingClientRect();
    const svgCenterX = rect.left + rect.width / 2;
    const svgCenterY = rect.top + rect.height / 2;
    const offsetX = clientX - svgCenterX;
    const offsetY = clientY - svgCenterY;

    // head
    const maxHeadOffset = 6;
    const headDistance = Math.sqrt(offsetX * offsetX + offsetY * offsetY);
    const clampedHeadDistance = Math.min(headDistance, maxHeadOffset);
    const headAngle = Math.atan2(offsetY, offsetX);
    const headMoveX = clampedHeadDistance * Math.cos(headAngle);
    const headMoveY = clampedHeadDistance * Math.sin(headAngle);

    // eyes
    const maxEyesOffset = 20;
    const eyesDistance = headDistance;
    const eyesAngle = headAngle;
    const clampedEyesDistance = Math.min(eyesDistance, maxEyesOffset);
    const eyeMoveX = clampedEyesDistance * Math.cos(eyesAngle);
    const eyeMoveY = clampedEyesDistance * Math.sin(eyesAngle);

    // ears
    const earMoveFactor = .5;
    const earMoveX = headMoveX * earMoveFactor;
        
    setPositions({
      head: {
        x: 90 + headMoveX,
        y: 110 + headMoveY
      },
      leftEye: {
        x: 77.5 + eyeMoveX,
        y: 112 + eyeMoveY
      },
      rightEye: {
        x: 103.5 + eyeMoveX,
        y: 112 + eyeMoveY
      },
      leftEar: {
        x: 60 - earMoveX,
      },
      rightEar: {
        x: 120 - earMoveX,
      },
    });
  }, []);

  const handleMouseMove = useCallback((event: MouseEvent) => {
    handleMove(event.clientX, event.clientY);
  }, [handleMove]);

  const handleTouchMove = useCallback((event: TouchEvent) => {
    if (event.touches.length > 0) {
      const touch = event.touches[0];
      handleMove(touch.clientX, touch.clientY);
    }
  }, [handleMove]);

  useEffect(() => {
    window.addEventListener("mousemove", handleMouseMove);
    window.addEventListener("touchmove", handleTouchMove);
    return () => {
      window.removeEventListener("mousemove", handleMouseMove);
      window.removeEventListener("touchmove", handleTouchMove);
    };
  }, [handleMouseMove, handleTouchMove]);

  // blink 
  useEffect(() => {
    const scheduleBlink = () => {
      const nextBlinkInterval = Math.random() * 3000 + 2000;
      blinkRef.current = setTimeout(() => {
        setIsBlinking(true);
        blinkRef.current = setTimeout(() => setIsBlinking(false), 200);
        scheduleBlink();
      }, nextBlinkInterval);
    };

    scheduleBlink();

    return () => {
      if (blinkRef.current) {
        clearTimeout(blinkRef.current);
      }
    };
  }, []);

  return (
    <svg 
      id="brand"
      width="96" 
      height="96" 
      viewBox="0 0 180 180" 
    >
      <defs>
        <clipPath id="backgroundClip">
          <circle cx="90" cy="90" r="90" />
        </clipPath>
      </defs>
      <g clipPath="url(#backgroundClip)">
        <circle 
          id="bg" 
          cx="90" 
          cy="90" 
          r="90" 
          className={cn("fill-zinc-900 dark:fill-zinc-100")}
        />
        <path 
          id="left-ear" 
          className={cn("fill-zinc-100 dark:fill-zinc-900")}
          d={`M${positions.leftEar.x} 34L${positions.leftEar.x} 34L127 180H17Z`} 
        />
        <path 
          id="right-ear" 
          className={cn("fill-zinc-100 dark:fill-zinc-900")}
          d={`M${positions.rightEar.x} 34L${positions.rightEar.x} 34L53 180H163Z`}
        />
        <circle 
          id="head" 
          r="42"
          cx={positions.head.x}
          cy={positions.head.y} 
          className={cn("fill-zinc-100 dark:fill-zinc-900")}
        />
        <ellipse 
          rx="7" 
          id="left-eye" 
          cx={positions.leftEye.x} 
          cy={positions.leftEye.y}
          ry={isBlinking ? "0" : "14"}
          className={cn("fill-zinc-900 dark:fill-zinc-100 transition-[ry] duration-150 ease-in-out")}
        />
        <ellipse 
          id="right-eye" 
          rx="7" 
          cx={positions.rightEye.x} 
          cy={positions.rightEye.y}
          ry={isBlinking ? "0" : "14"}
          className={cn("fill-zinc-900 dark:fill-zinc-100 transition-[ry] duration-150 ease-in-out")}
        />
      </g>
    </svg>
  );
}