Files
stats/apps/start/src/components/facehash/facehash.tsx

428 lines
11 KiB
TypeScript

import * as React from 'react';
import { FACES } from './faces';
import { stringHash } from './utils/hash';
// ============================================================================
// Types
// ============================================================================
export type Intensity3D = 'none' | 'subtle' | 'medium' | 'dramatic';
export type Variant = 'gradient' | 'solid';
export type ColorScheme = 'light' | 'dark' | 'auto';
export interface FacehashProps
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;
/**
* Use Tailwind group-hover for hover detection.
* When true, hover effect triggers when a parent with "group" class is hovered.
* @default false
*/
groupHover?: boolean;
/**
* Show first letter of name below the face.
* @default true
*/
showInitial?: boolean;
/**
* Hex color array for inline styles.
* Use this OR colorClasses, not both.
*/
colors?: string[];
/**
* Colors to use in light mode.
* Used when colorScheme is "light" or "auto".
*/
colorsLight?: string[];
/**
* Colors to use in dark mode.
* Used when colorScheme is "dark" or "auto".
*/
colorsDark?: string[];
/**
* Which color scheme to use.
* - "light": Always use colorsLight
* - "dark": Always use colorsDark
* - "auto": Use CSS prefers-color-scheme media query
* @default "auto"
*/
colorScheme?: ColorScheme;
/**
* 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 color palettes
export const DEFAULT_COLORS = [
'#fce7f3', // pink-100
'#fef3c7', // amber-100
'#dbeafe', // blue-100
'#d1fae5', // emerald-100
'#ede9fe', // violet-100
'#fee2e2', // red-100
'#e0e7ff', // indigo-100
'#ccfbf1', // teal-100
];
export const DEFAULT_COLORS_LIGHT = DEFAULT_COLORS;
export const DEFAULT_COLORS_DARK = [
'#db2777', // pink-600
'#d97706', // amber-600
'#2563eb', // blue-600
'#059669', // emerald-600
'#7c3aed', // violet-600
'#dc2626', // red-600
'#4f46e5', // indigo-600
'#0d9488', // teal-600
];
// Default gradient as pure CSS (works without Tailwind)
const DEFAULT_GRADIENT_STYLE: React.CSSProperties = {
background:
'radial-gradient(ellipse 100% 100% at 50% 50%, rgba(255,255,255,0.15) 0%, transparent 60%)',
};
// ============================================================================
// Component
// ============================================================================
/**
* Facehash - Deterministic avatar faces from any string.
*/
/**
* Hook to detect system color scheme preference
*/
function useColorScheme(colorScheme: ColorScheme): 'light' | 'dark' {
const [systemScheme, setSystemScheme] = React.useState<'light' | 'dark'>(
() => {
if (typeof window === 'undefined') {
return 'light';
}
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
);
React.useEffect(() => {
if (colorScheme !== 'auto') {
return;
}
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
setSystemScheme(e.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [colorScheme]);
if (colorScheme === 'auto') {
return systemScheme;
}
return colorScheme;
}
export const Facehash = React.forwardRef<HTMLDivElement, FacehashProps>(
(
{
name,
size = 40,
variant = 'gradient',
intensity3d = 'dramatic',
interactive = true,
showInitial = true,
colors,
colorsLight,
colorsDark,
colorScheme = 'auto',
colorClasses,
gradientOverlayClass,
groupHover = false,
className,
style,
onMouseEnter,
onMouseLeave,
...props
},
ref
) => {
const [isHovered, setIsHovered] = React.useState(false);
const resolvedScheme = useColorScheme(colorScheme);
// For group-hover, we use CSS instead of JS state
const usesCssHover = groupHover;
// Determine which colors to use based on scheme
const effectiveColors = React.useMemo(() => {
// If explicit colors prop is provided, use it
if (colors) {
return colors;
}
// If colorClasses is provided, don't use inline colors
if (colorClasses) {
return undefined;
}
// Use scheme-specific colors or defaults
const lightColors = colorsLight ?? DEFAULT_COLORS_LIGHT;
const darkColors = colorsDark ?? DEFAULT_COLORS_DARK;
return resolvedScheme === 'dark' ? darkColors : lightColors;
}, [colors, colorClasses, colorsLight, colorsDark, resolvedScheme]);
// Generate deterministic values from name
const { FaceComponent, colorIndex, rotation } = React.useMemo(() => {
const hash = stringHash(name);
const faceIndex = hash % FACES.length;
const colorsLength = colorClasses?.length ?? effectiveColors?.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, effectiveColors?.length, colorClasses?.length]);
// Get intensity preset
const preset = INTENSITY_PRESETS[intensity3d];
// Calculate 3D transforms
const { baseTransform, hoverTransform } = React.useMemo(() => {
if (intensity3d === 'none') {
return { baseTransform: undefined, hoverTransform: undefined };
}
const rotateX = rotation.x * preset.rotateRange;
const rotateY = rotation.y * preset.rotateRange;
return {
baseTransform: `rotateX(${rotateX}deg) rotateY(${rotateY}deg) translateZ(${preset.translateZ}px)`,
hoverTransform: `rotateX(0deg) rotateY(0deg) translateZ(${preset.translateZ}px)`,
};
}, [intensity3d, rotation, preset]);
// For JS-based hover, apply transform based on hover state
const transform = React.useMemo(() => {
if (usesCssHover || !interactive) {
return baseTransform;
}
return isHovered ? hoverTransform : baseTransform;
}, [usesCssHover, interactive, isHovered, baseTransform, hoverTransform]);
// Size style
const sizeValue = typeof size === 'number' ? `${size}px` : size;
// Initial letter
const initial = name.charAt(0).toUpperCase();
// Background: either hex color (inline) or class
const bgColorClass = colorClasses?.[colorIndex];
const bgColorHex = effectiveColors?.[colorIndex];
// Event handlers (only used for JS-based hover, not group-hover)
const handleMouseEnter = React.useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (interactive && !usesCssHover) {
setIsHovered(true);
}
onMouseEnter?.(e);
},
[interactive, usesCssHover, onMouseEnter]
);
const handleMouseLeave = React.useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (interactive && !usesCssHover) {
setIsHovered(false);
}
onMouseLeave?.(e);
},
[interactive, usesCssHover, onMouseLeave]
);
return (
<div
aria-label={`Avatar for ${name}`}
className={`${bgColorClass ?? ''} ${className ?? ''}`}
data-facehash-avatar=""
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={ref}
role="img"
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: sizeValue,
height: sizeValue,
perspective: preset.perspective,
color: 'currentColor',
...(bgColorHex && { backgroundColor: bgColorHex }),
...style,
}}
{...props}
>
{/* Gradient overlay */}
{variant === 'gradient' && (
<div
aria-hidden="true"
className={gradientOverlayClass}
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
...(gradientOverlayClass ? {} : DEFAULT_GRADIENT_STYLE),
}}
/>
)}
{/* Face container with 3D transform */}
<div
className={
usesCssHover && interactive
? 'group-hover:[transform:var(--facehash-hover-transform)]'
: undefined
}
data-facehash-avatar-face=""
style={
{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transform,
transition: interactive ? 'transform 0.2s ease-out' : undefined,
transformStyle: 'preserve-3d',
'--facehash-hover-transform': hoverTransform,
} as React.CSSProperties
}
>
{/* Face SVG */}
<FaceComponent
style={{
position: 'absolute',
width: '100%',
height: '100%',
}}
/>
{/* Initial letter */}
{showInitial && (
<span
data-facehash-avatar-initial=""
style={{
position: 'relative',
marginTop: '25%',
fontSize: `calc(${sizeValue} * 0.35)`,
fontWeight: 600,
lineHeight: 1,
userSelect: 'none',
}}
>
{initial}
</span>
)}
</div>
</div>
);
}
);
Facehash.displayName = 'Facehash';