import * as React from "react"; import { isColorDark } from "~/lib/color"; import { FACES } from "./faces"; // from https://github.com/cossistantcom/cossistant/tree/main/packages/facehash type Intensity3D = "none" | "subtle" | "medium" | "dramatic"; type Variant = "gradient" | "solid"; interface FacehashProps extends Omit, "children"> { /** * String to generate a deterministic face from. * Same string always produces the same face. */ name: string; /** * Size in pixels or CSS units. * @default 40 */ size?: number | string; /** * Background style. * - "gradient": Adds gradient overlay (default) * - "solid": Plain background color * @default "gradient" */ variant?: Variant; /** * 3D effect intensity. * @default "dramatic" */ intensity3d?: Intensity3D; /** * Enable hover interaction. * When true, face "looks straight" on hover. * @default true */ interactive?: boolean; /** * Show first letter of name below the face. * @default true */ showInitial?: boolean; initialsCount?: number; /** * Hex color array for inline styles. * Use this OR colorClasses, not both. */ colors?: string[]; /** * Tailwind class array for background colors. * Example: ["bg-pink-500 dark:bg-pink-600", "bg-blue-500 dark:bg-blue-600"] * Use this OR colors, not both. */ colorClasses?: string[]; /** * Custom gradient overlay class (Tailwind). * When provided, replaces the default pure CSS gradient. * Only used when variant="gradient". */ gradientOverlayClass?: string; } // ============================================================================ // Constants // ============================================================================ const INTENSITY_PRESETS = { none: { rotateRange: 0, translateZ: 0, perspective: "none", }, subtle: { rotateRange: 5, translateZ: 4, perspective: "800px", }, medium: { rotateRange: 10, translateZ: 8, perspective: "500px", }, dramatic: { rotateRange: 15, translateZ: 12, perspective: "300px", }, } as const; const SPHERE_POSITIONS = [ { x: -1, y: 1 }, // down-right { x: 1, y: 1 }, // up-right { x: 1, y: 0 }, // up { x: 0, y: 1 }, // right { x: -1, y: 0 }, // down { x: 0, y: 0 }, // center { x: 0, y: -1 }, // left { x: -1, y: -1 }, // down-left { x: 1, y: -1 }, // up-left ] as const; // Default gradient as pure CSS (works without Tailwind) // Matches: bg-[radial-gradient(ellipse_100%_100%_at_50%_50%,_COLOR_0%,_transparent_60%)] // Light mode: white glow in center, Dark mode: dark overlay in center const DEFAULT_GRADIENT_STYLE: React.CSSProperties = { background: "radial-gradient(ellipse 100% 100% at 50% 50%, rgba(255,255,255,0.15) 0%, transparent 60%)", }; /** * Facehash - Deterministic avatar faces from any string. * * @example * ```tsx * // With Tailwind classes * * * // With hex colors * * * // Plain color (no gradient) * * ``` */ export const Facehash = React.forwardRef( ( { name, size = 40, variant = "gradient", intensity3d = "dramatic", interactive = true, showInitial = true, initialsCount = 1, colors, colorClasses, gradientOverlayClass, className, style, onMouseEnter, onMouseLeave, ...props }, ref, ) => { const [isHovered, setIsHovered] = React.useState(false); // Generate deterministic values from name const { FaceComponent, colorIndex, rotation } = React.useMemo(() => { const hash = stringHash(name); const faceIndex = hash % FACES.length; const colorsLength = colorClasses?.length ?? colors?.length ?? 1; const _colorIndex = hash % colorsLength; const positionIndex = hash % SPHERE_POSITIONS.length; const position = SPHERE_POSITIONS[positionIndex] ?? { x: 0, y: 0 }; return { FaceComponent: FACES[faceIndex] ?? FACES[0], colorIndex: _colorIndex, rotation: position, }; }, [name, colors?.length, colorClasses?.length]); // Get intensity preset const preset = INTENSITY_PRESETS[intensity3d]; // Calculate 3D transform const transform = React.useMemo(() => { if (intensity3d === "none") { return; } const rotateX = isHovered && interactive ? 0 : rotation.x * preset.rotateRange; const rotateY = isHovered && interactive ? 0 : rotation.y * preset.rotateRange; return `rotateX(${rotateX}deg) rotateY(${rotateY}deg) translateZ(${preset.translateZ}px)`; }, [intensity3d, isHovered, interactive, rotation, preset]); // Size style const sizeValue = typeof size === "number" ? `${size}px` : size; // Initial letter const initial = name.trim().substring(0, initialsCount).toUpperCase(); // Background: either hex color (inline) or class const bgColorClass = colorClasses?.[colorIndex]; const bgColorHex = colors?.[colorIndex]; // Event handlers const handleMouseEnter = React.useCallback( (e: React.MouseEvent) => { if (interactive) { setIsHovered(true); } onMouseEnter?.(e); }, [interactive, onMouseEnter], ); const handleMouseLeave = React.useCallback( (e: React.MouseEvent) => { if (interactive) { setIsHovered(false); } onMouseLeave?.(e); }, [interactive, onMouseLeave], ); return ( // biome-ignore lint/a11y/noNoninteractiveElementInteractions: Hover effect is purely cosmetic // biome-ignore lint/a11y/noStaticElementInteractions: This is a decorative avatar component
{/* Gradient overlay */} {variant === "gradient" && (
)} {/* Face container with 3D transform */}
{/* Face SVG */} {/* Initial letter */} {showInitial && ( {initial} )}
); }, ); Facehash.displayName = "Facehash"; /** * Generates a consistent numeric hash from a string. * Used to deterministically select faces and colors for avatars. * * @param str - The input string to hash * @returns A positive 32-bit integer hash */ function stringHash(str: string): number { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; hash &= hash; // Convert to 32bit integer } return Math.abs(hash); }