2026-01-30 15:24:09 +01:00
|
|
|
import * as React from "react";
|
|
|
|
|
import { isColorDark } from "~/lib/color";
|
|
|
|
|
import { FACES } from "./faces";
|
|
|
|
|
|
|
|
|
|
// from https://github.com/cossistantcom/cossistant/tree/main/packages/facehash
|
|
|
|
|
|
2026-02-09 11:45:41 +01:00
|
|
|
type Intensity3D = "none" | "subtle" | "medium" | "dramatic";
|
|
|
|
|
type Variant = "gradient" | "solid";
|
2026-01-30 15:24:09 +01:00
|
|
|
|
2026-02-09 11:45:41 +01:00
|
|
|
interface FacehashProps
|
2026-01-30 15:24:09 +01:00
|
|
|
extends Omit<React.HTMLAttributes<HTMLDivElement>, "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
|
|
|
|
|
* <Facehash
|
|
|
|
|
* name="John"
|
|
|
|
|
* colorClasses={["bg-pink-500", "bg-blue-500"]}
|
|
|
|
|
* />
|
|
|
|
|
*
|
|
|
|
|
* // With hex colors
|
|
|
|
|
* <Facehash
|
|
|
|
|
* name="John"
|
|
|
|
|
* colors={["#ec4899", "#3b82f6"]}
|
|
|
|
|
* />
|
|
|
|
|
*
|
|
|
|
|
* // Plain color (no gradient)
|
|
|
|
|
* <Facehash name="John" variant="solid" />
|
|
|
|
|
* ```
|
|
|
|
|
*/
|
|
|
|
|
export const Facehash = React.forwardRef<HTMLDivElement, FacehashProps>(
|
|
|
|
|
(
|
|
|
|
|
{
|
|
|
|
|
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<HTMLDivElement>) => {
|
|
|
|
|
if (interactive) {
|
|
|
|
|
setIsHovered(true);
|
|
|
|
|
}
|
|
|
|
|
onMouseEnter?.(e);
|
|
|
|
|
},
|
|
|
|
|
[interactive, onMouseEnter],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleMouseLeave = React.useCallback(
|
|
|
|
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
|
|
|
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
|
|
|
|
|
<div
|
|
|
|
|
className={[bgColorClass, className].filter(Boolean).join(" ")}
|
|
|
|
|
data-facehash=""
|
|
|
|
|
data-interactive={interactive || undefined}
|
|
|
|
|
onMouseEnter={handleMouseEnter}
|
|
|
|
|
onMouseLeave={handleMouseLeave}
|
|
|
|
|
ref={ref}
|
|
|
|
|
style={{
|
|
|
|
|
// Size
|
|
|
|
|
width: sizeValue,
|
|
|
|
|
height: sizeValue,
|
|
|
|
|
// Layout
|
|
|
|
|
position: "relative",
|
|
|
|
|
display: "flex",
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
justifyContent: "center",
|
|
|
|
|
overflow: "hidden",
|
|
|
|
|
// Container for cqw units
|
|
|
|
|
containerType: "size",
|
|
|
|
|
// 3D setup
|
|
|
|
|
...(intensity3d !== "none" && {
|
|
|
|
|
perspective: preset.perspective,
|
|
|
|
|
transformStyle: "preserve-3d",
|
|
|
|
|
}),
|
|
|
|
|
// Background color (hex) - only if no colorClasses
|
|
|
|
|
...(bgColorHex && !bgColorClass && { backgroundColor: bgColorHex }),
|
|
|
|
|
// User styles (last to allow overrides)
|
|
|
|
|
...style,
|
|
|
|
|
}}
|
|
|
|
|
{...props}
|
|
|
|
|
>
|
|
|
|
|
{/* Gradient overlay */}
|
|
|
|
|
{variant === "gradient" && (
|
|
|
|
|
<div
|
|
|
|
|
className={gradientOverlayClass}
|
|
|
|
|
data-facehash-gradient=""
|
|
|
|
|
style={{
|
|
|
|
|
position: "absolute",
|
|
|
|
|
inset: 0,
|
|
|
|
|
pointerEvents: "none",
|
|
|
|
|
zIndex: 1,
|
|
|
|
|
// Use default pure CSS gradient if no class provided
|
|
|
|
|
...(gradientOverlayClass ? {} : DEFAULT_GRADIENT_STYLE),
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Face container with 3D transform */}
|
|
|
|
|
<div
|
|
|
|
|
data-facehash-face=""
|
|
|
|
|
style={{
|
|
|
|
|
position: "absolute",
|
|
|
|
|
inset: 0,
|
|
|
|
|
display: "flex",
|
|
|
|
|
flexDirection: "column",
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
justifyContent: "center",
|
|
|
|
|
zIndex: 2,
|
|
|
|
|
transform,
|
|
|
|
|
transformStyle: intensity3d !== "none" ? "preserve-3d" : undefined,
|
|
|
|
|
transition: interactive
|
|
|
|
|
? "transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
|
|
|
|
: undefined,
|
|
|
|
|
// Default to black text/icons for contrast on colored backgrounds
|
|
|
|
|
//color: "#000000",
|
|
|
|
|
color: bgColorHex
|
|
|
|
|
? isColorDark(bgColorHex, 100)
|
|
|
|
|
? "#FFFFFF"
|
|
|
|
|
: "#000000"
|
|
|
|
|
: "#000000",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{/* Face SVG */}
|
|
|
|
|
<FaceComponent
|
|
|
|
|
style={{
|
|
|
|
|
width: "60%",
|
|
|
|
|
height: "auto",
|
|
|
|
|
maxWidth: "90%",
|
|
|
|
|
maxHeight: "40%",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Initial letter */}
|
|
|
|
|
{showInitial && (
|
|
|
|
|
<span
|
|
|
|
|
data-facehash-initial=""
|
|
|
|
|
style={{
|
|
|
|
|
marginTop: "8%",
|
|
|
|
|
fontSize: "26cqw",
|
|
|
|
|
lineHeight: 1,
|
|
|
|
|
fontFamily: "monospace",
|
|
|
|
|
fontWeight: "bold",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{initial}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|