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 Speed1x
Avatar Size240px
Show Eyes
Photon Ring
Symmetric Disk
Mouse Tracking
// 02·USAGE
example.tsximport { 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 propsProp
Type
// 04·COMPONENT SOURCE
2 files"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;