Black Hole Mascot

Meet Denu - he sucks all your energy.

// 01·LIVE PREVIEW
Interactive stage
// 01.B·PARAMETERS
Color Theme
Shading Style
Event Horizon
Bot Expression
Orbital Speed
1x
Avatar Size
240px
Show Eyes
Photon Ring
Symmetric Disk
Mouse Tracking
// 02·USAGE
example.tsx
example.tsx
import { BlackHoleAvatar } from "@/components/showcase/black-hole/black-hole-avatar";

export function Example() {
  return (
    <BlackHoleAvatar
      size={240}
      colorTheme="cyan"
      diskStyle="smooth"
      deformStyle="rounded"
      eyeStyle="pill"
      speed={1.0}
      interactive={true}
    />
  );
}
// 03·API REFERENCE
11 props

Prop

Type

// 04·COMPONENT SOURCE
2 files
BlackHoleAvatar.tsx
"use client";

import React, { useCallback, useEffect, useId, useRef } from "react";
import { cn } from "@/lib/cn";
import { FRAGMENT_SHADER_SRC, VERTEX_SHADER_SRC } from "./black-hole-shaders";

export type EyeStyle =
  | "pill"
  | "aggressive"
  | "fierce"
  | "singularity"
  | "none";
export type ColorTheme =
  | "monochrome"
  | "blue"
  | "cyan"
  | "violet"
  | "amber"
  | "crimson";
export type CoreDeformStyle =
  | "rounded"
  | "organic"
  | "coronal"
  | "ripple"
  | "crystalline";
export type AccretionDiskStyle = "smooth" | "dither";

export interface BlackHoleAvatarProps
  extends React.HTMLAttributes<HTMLDivElement> {
  size?: number | "sm" | "md" | "lg" | "xl" | "2xl";

  speed?: number;

  showEyes?: boolean;

  eyeStyle?: EyeStyle;

  colorTheme?: ColorTheme;

  deformStyle?: CoreDeformStyle;

  showPhotonRing?: boolean;

  symmetric?: boolean;

  diskStyle?: AccretionDiskStyle;

  renderStyle?: AccretionDiskStyle;

  pixelSize?: number;

  interactive?: boolean;

  audioLevel?: number;
}

const SIZE_PRESETS = {
  sm: 48,
  md: 96,
  lg: 160,
  xl: 240,
  "2xl": 320,
};

export const BlackHoleAvatar = React.forwardRef<
  HTMLDivElement,
  BlackHoleAvatarProps
>(
  (
    {
      size = 160,
      speed = 1.0,
      showEyes = true,
      eyeStyle = "pill",
      colorTheme = "monochrome",
      deformStyle = "rounded",
      showPhotonRing = true,
      symmetric = false,
      diskStyle = "smooth",
      renderStyle,
      pixelSize = 3,
      interactive = true,
      audioLevel = 0,
      className,
      ...props
    },
    ref,
  ) => {
    const canvasRef = useRef<HTMLCanvasElement | null>(null);
    const containerRef = useRef<HTMLDivElement | null>(null);
    const animFrameRef = useRef<number | null>(null);
    const activeDiskStyle = renderStyle || diskStyle;

    const mousePos = useRef({ x: 0, y: 0, targetX: 0, targetY: 0 });
    const blinkTimer = useRef({
      isBlinking: false,
      progress: 0,
      nextBlink: 120,
    });
    const timeRef = useRef(0);

    const actualSize =
      typeof size === "string" ? (SIZE_PRESETS[size] ?? 160) : size;
    const canvasId = useId();

    const handleMouseMove = useCallback(
      (e: React.MouseEvent<HTMLDivElement>) => {
        if (!interactive || !containerRef.current) return;
        const rect = containerRef.current.getBoundingClientRect();
        const centerX = rect.left + rect.width / 2;
        const centerY = rect.top + rect.height / 2;
        const dx = (e.clientX - centerX) / (rect.width / 2);
        const dy = (e.clientY - centerY) / (rect.height / 2);

        const dist = Math.min(1.2, Math.hypot(dx, dy));
        const angle = Math.atan2(dy, dx);
        mousePos.current.targetX = Math.cos(angle) * dist;
        mousePos.current.targetY = Math.sin(angle) * dist;
      },
      [interactive],
    );

    const handleMouseLeave = useCallback(() => {
      mousePos.current.targetX = 0;
      mousePos.current.targetY = 0;
    }, []);

    useEffect(() => {
      const canvas = canvasRef.current;
      if (!canvas) return;

      const gl = canvas.getContext("webgl", {
        alpha: true,
        premultipliedAlpha: true,
        antialias: false,
      });

      if (!gl) return;

      const dpr =
        typeof window !== "undefined"
          ? Math.min(window.devicePixelRatio || 1, 2)
          : 1;
      const width = actualSize;
      const height = actualSize;
      canvas.width = Math.round(width * dpr);
      canvas.height = Math.round(height * dpr);
      canvas.style.width = `${width}px`;
      canvas.style.height = `${height}px`;

      const compileShader = (type: number, src: string) => {
        const shader = gl.createShader(type);
        if (!shader) return null;
        gl.shaderSource(shader, src);
        gl.compileShader(shader);
        if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
          console.error(
            "[BlackHole Shader Error]:",
            gl.getShaderInfoLog(shader),
          );
          gl.deleteShader(shader);
          return null;
        }
        return shader;
      };

      const vs = compileShader(gl.VERTEX_SHADER, VERTEX_SHADER_SRC);
      const fs = compileShader(gl.FRAGMENT_SHADER, FRAGMENT_SHADER_SRC);
      if (!vs || !fs) return;

      const program = gl.createProgram();
      if (!program) return;
      gl.attachShader(program, vs);
      gl.attachShader(program, fs);
      gl.linkProgram(program);

      if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
        console.error(
          "[BlackHole Program Error]:",
          gl.getProgramInfoLog(program),
        );
        return;
      }

      gl.useProgram(program);

      const quadBuffer = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
      gl.bufferData(
        gl.ARRAY_BUFFER,
        new Float32Array([-1, -1, 3, -1, -1, 3]),
        gl.STATIC_DRAW,
      );

      const posLoc = gl.getAttribLocation(program, "a_pos");
      gl.enableVertexAttribArray(posLoc);
      gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);

      const uRes = gl.getUniformLocation(program, "u_res");
      const uTime = gl.getUniformLocation(program, "u_time");
      const uMouse = gl.getUniformLocation(program, "u_mouse");
      const uAudio = gl.getUniformLocation(program, "u_audio");
      const uSpeed = gl.getUniformLocation(program, "u_speed");
      const uShowEyes = gl.getUniformLocation(program, "u_show_eyes");
      const uEyeStyle = gl.getUniformLocation(program, "u_eye_style");
      const uBlink = gl.getUniformLocation(program, "u_blink");
      const uTheme = gl.getUniformLocation(program, "u_theme");
      const uDeformStyle = gl.getUniformLocation(program, "u_deform_style");
      const uShowPhotonRing = gl.getUniformLocation(
        program,
        "u_show_photon_ring",
      );
      const uDiskStyle = gl.getUniformLocation(program, "u_disk_style");
      const uPixelSize = gl.getUniformLocation(program, "u_pixel_size");
      const uSymmetric = gl.getUniformLocation(program, "u_symmetric");

      gl.viewport(0, 0, canvas.width, canvas.height);
      gl.uniform2f(uRes, canvas.width, canvas.height);

      const themeMap: Record<ColorTheme, number> = {
        monochrome: 0,
        blue: 1,
        yuvich: 1,
        cyan: 2,
        grok: 2,
        violet: 3,
        amber: 4,
        crimson: 5,
      };

      const deformStyleMap: Record<CoreDeformStyle, number> = {
        rounded: 0,
        organic: 1,
        coronal: 2,
        ripple: 3,
        crystalline: 4,
      };

      const eyeStyleMap: Record<EyeStyle, number> = {
        pill: 0,
        grok: 0,
        aggressive: 1,
        fierce: 2,
        danger: 2,
        singularity: 3,
        none: 4,
      };

      let running = true;

      const render = () => {
        if (!running) return;

        timeRef.current += 0.016 * speed;
        const t = timeRef.current;

        const lerpFactor = 0.08;
        mousePos.current.x +=
          (mousePos.current.targetX - mousePos.current.x) * lerpFactor;
        mousePos.current.y +=
          (mousePos.current.targetY - mousePos.current.y) * lerpFactor;

        const blink = blinkTimer.current;
        blink.nextBlink--;
        if (blink.nextBlink <= 0 && !blink.isBlinking) {
          blink.isBlinking = true;
          blink.progress = 0;
          blink.nextBlink = 90 + Math.floor(Math.random() * 180);
        }
        let blinkVal = 0;
        if (blink.isBlinking) {
          blink.progress += 0.18;
          blinkVal = Math.sin(blink.progress);
          if (blink.progress >= Math.PI) {
            blink.isBlinking = false;
            blink.progress = 0;
            blinkVal = 0;
          }
        }

        gl.uniform1f(uTime, t);
        gl.uniform2f(uMouse, mousePos.current.x, -mousePos.current.y);
        gl.uniform1f(uAudio, audioLevel);
        gl.uniform1f(uSpeed, speed);
        gl.uniform1i(uShowEyes, showEyes ? 1 : 0);
        gl.uniform1i(uEyeStyle, eyeStyleMap[eyeStyle] ?? 0);
        gl.uniform1f(uBlink, Math.max(0, Math.min(1, blinkVal)));
        gl.uniform1i(uTheme, themeMap[colorTheme] ?? 0);
        gl.uniform1i(uDeformStyle, deformStyleMap[deformStyle] ?? 0);
        gl.uniform1i(uShowPhotonRing, showPhotonRing ? 1 : 0);
        gl.uniform1i(uSymmetric, symmetric ? 1 : 0);

        const isDither = activeDiskStyle === "dither";
        gl.uniform1i(uDiskStyle, isDither ? 1 : 0);
        const pxSizeVal = Math.max(1.0, (pixelSize || 3) * dpr);
        gl.uniform1f(uPixelSize, pxSizeVal);

        gl.clearColor(0.0, 0.0, 0.0, 0.0);
        gl.clear(gl.COLOR_BUFFER_BIT);
        gl.drawArrays(gl.TRIANGLES, 0, 3);

        animFrameRef.current = requestAnimationFrame(render);
      };

      animFrameRef.current = requestAnimationFrame(render);

      return () => {
        running = false;
        if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
        if (quadBuffer) gl.deleteBuffer(quadBuffer);
        if (program) gl.deleteProgram(program);
        if (vs) gl.deleteShader(vs);
        if (fs) gl.deleteShader(fs);
      };
    }, [
      actualSize,
      speed,
      showEyes,
      eyeStyle,
      colorTheme,
      deformStyle,
      showPhotonRing,
      symmetric,
      audioLevel,
      activeDiskStyle,
      pixelSize,
    ]);

    return (
      <div
        ref={(node) => {
          containerRef.current = node;
          if (typeof ref === "function") ref(node);
          else if (ref)
            (ref as React.MutableRefObject<HTMLDivElement | null>).current =
              node;
        }}
        role="img"
        aria-label="Relativistic black hole bot avatar"
        tabIndex={interactive ? 0 : undefined}
        onMouseMove={handleMouseMove}
        onMouseLeave={handleMouseLeave}
        className={cn(
          "relative inline-flex items-center justify-center select-none overflow-visible transition-transform duration-300",
          interactive && "cursor-pointer active:scale-95 hover:scale-105",
          className,
        )}
        style={{ width: actualSize, height: actualSize }}
        {...props}
      >
        <canvas
          id={canvasId}
          ref={canvasRef}
          className="absolute inset-0 block w-full h-full pointer-events-none rounded-none"
        />
      </div>
    );
  },
);

BlackHoleAvatar.displayName = "BlackHoleAvatar";

export default BlackHoleAvatar;
IHSOR Discrete Fourier Transform 124-Harmonics Epicycle Synthesis Wordmark
@ 2024-2026 ihsor.dev