diff --git a/apps/www/src/content/docs/components/avatar/demo.ts b/apps/www/src/content/docs/components/avatar/demo.ts
index 8505fbcf4..75109d6e0 100644
--- a/apps/www/src/content/docs/components/avatar/demo.ts
+++ b/apps/www/src/content/docs/components/avatar/demo.ts
@@ -133,9 +133,35 @@ export const imageDemo = {
export const generatedColorDemo = {
type: 'code',
code: `
-
-
- `
+ function GeneratedColors() {
+ const people = [
+ { name: "Ravi Chopra", initials: "RC" },
+ { name: "Alice", initials: "A" },
+ { name: "Bob", initials: "B" },
+ { name: "amy", initials: "AM" },
+ { name: "may", initials: "MA" }
+ ];
+
+ return (
+
+
+ {people.map(({ name, initials }) => (
+
+ ))}
+
+
+ {people.map(({ name, initials }) => (
+
+ ))}
+
+
+ );
+ }`
};
export const groupDemo = {
diff --git a/apps/www/src/content/docs/components/avatar/index.mdx b/apps/www/src/content/docs/components/avatar/index.mdx
index 55157b8cb..ae3df023a 100644
--- a/apps/www/src/content/docs/components/avatar/index.mdx
+++ b/apps/www/src/content/docs/components/avatar/index.mdx
@@ -68,7 +68,21 @@ Avatar can display user images with graceful fallback to initials when images fa
### With generated colors
-use `getAvatarColor` utility to generate colors based on a string.
+`getAvatarColor` derives a color from a string, such as a user's email. The same string always returns the same color, on the server and in the browser. Pass a stable ID, such as `user.id`, so the color does not change when someone is renamed.
+
+```tsx
+getAvatarColor(user.email);
+
+// Pick only from these colors.
+getAvatarColor(user.email, { palette: ['indigo', 'mint', 'sky'] });
+```
+
+`AVATAR_COLORS` lists every color `getAvatarColor` can return. Use it to build a `palette`, for example to leave out `'neutral'`, which `AvatarGroup` uses for the overflow count:
+
+```tsx
+const palette = AVATAR_COLORS.filter(color => color !== 'neutral');
+getAvatarColor(user.email, { palette });
+```
@@ -94,6 +108,12 @@ Groups multiple avatars with overlap and count indicator.
+### getAvatarColor
+
+`getAvatarColor(str: string, options?: GetAvatarColorOptions)` returns one of the colors in `AVATAR_COLORS`.
+
+
+
### Slots
Every rendered part carries a stable `data-slot` attribute for [styling and testing](/docs/styling#with-data-slot):
diff --git a/apps/www/src/content/docs/components/avatar/props.ts b/apps/www/src/content/docs/components/avatar/props.ts
index 74d84977b..964bbdbe2 100644
--- a/apps/www/src/content/docs/components/avatar/props.ts
+++ b/apps/www/src/content/docs/components/avatar/props.ts
@@ -77,3 +77,25 @@ export interface AvatarGroupProps {
/** Additional CSS class names */
className?: string;
}
+
+export interface GetAvatarColorOptions {
+ /**
+ * Restricts the result to these colors. Order matters. If empty, all colors are used.
+ * @defaultValue `AVATAR_COLORS`
+ */
+ palette?: Array<
+ | 'indigo'
+ | 'orange'
+ | 'mint'
+ | 'neutral'
+ | 'sky'
+ | 'lime'
+ | 'grass'
+ | 'cyan'
+ | 'iris'
+ | 'purple'
+ | 'pink'
+ | 'crimson'
+ | 'gold'
+ >;
+}
diff --git a/packages/raystack/components/avatar/__tests__/avatar.test.tsx b/packages/raystack/components/avatar/__tests__/avatar.test.tsx
index ad08811aa..60917cfc0 100644
--- a/packages/raystack/components/avatar/__tests__/avatar.test.tsx
+++ b/packages/raystack/components/avatar/__tests__/avatar.test.tsx
@@ -4,7 +4,7 @@ import { radiusClasses } from '../../../shared/radius';
import { Tooltip } from '../../tooltip';
import { Avatar, AvatarGroup } from '../avatar';
import styles from '../avatar.module.css';
-import { getAvatarColor } from '../utils';
+import { AVATAR_COLORS, getAvatarColor } from '../utils';
describe('Avatar', () => {
const ogImage = window.Image;
@@ -337,43 +337,53 @@ describe('Avatar', () => {
describe('Utility Functions', () => {
describe('getAvatarColor', () => {
- it('returns consistent color for same string', () => {
- const color1 = getAvatarColor('john.doe@example.com');
- const color2 = getAvatarColor('john.doe@example.com');
- expect(color1).toBe(color2);
+ it('maps anagrams to different colors', () => {
+ expect(getAvatarColor('abc')).toBe('iris');
+ expect(getAvatarColor('cba')).toBe('neutral');
+ expect(getAvatarColor('amy')).toBe('neutral');
+ expect(getAvatarColor('may')).toBe('cyan');
+ expect(getAvatarColor('night')).toBe('mint');
+ expect(getAvatarColor('thing')).toBe('purple');
});
- it('returns different colors for different strings', () => {
- const color1 = getAvatarColor('user1');
- const color2 = getAvatarColor('user2');
- // While not guaranteed to be different, testing with known different hashes
- const colors = new Set([
- color1,
- color2,
- getAvatarColor('user3'),
- getAvatarColor('user4')
- ]);
- expect(colors.size).toBeGreaterThan(1);
+ it('maps anagrams to different colors with a 2-color palette', () => {
+ const palette = ['sky', 'mint'] as const;
+ expect(getAvatarColor('amy', { palette })).toBe('mint');
+ expect(getAvatarColor('may', { palette })).toBe('sky');
});
- it('returns valid avatar color', () => {
- const validColors = [
- 'indigo',
- 'orange',
- 'mint',
- 'neutral',
- 'sky',
- 'lime',
- 'grass',
- 'cyan',
- 'iris',
- 'purple',
- 'pink',
- 'crimson',
- 'gold'
- ];
- const color = getAvatarColor('test');
- expect(validColors).toContain(color);
+ it('returns a known color for a known string', () => {
+ expect(getAvatarColor('john.doe@example.com')).toBe('mint');
+ });
+
+ it('returns a valid color for an empty string', () => {
+ expect(getAvatarColor('')).toBe('pink');
+ });
+
+ it('returns only colors from the palette', () => {
+ const palette = ['indigo', 'mint', 'sky'] as const;
+ for (let i = 0; i < 200; i++) {
+ expect(palette).toContain(getAvatarColor(`u${i}`, { palette }));
+ }
+ });
+
+ it('uses every color over many strings', () => {
+ const hit = new Set(
+ Array.from({ length: 1000 }, (_, i) => getAvatarColor(`user-${i}`))
+ );
+ expect(hit.size).toBe(AVATAR_COLORS.length);
+ });
+
+ it('has a color variant class for every color', () => {
+ for (const color of AVATAR_COLORS) {
+ const { container, unmount } = render(
+
+ );
+ const className = styles[`avatar-color-${color}`];
+ expect(className).toBeTruthy();
+ expect(container.firstElementChild).toHaveClass(className);
+ unmount();
+ }
});
});
});
diff --git a/packages/raystack/components/avatar/avatar.tsx b/packages/raystack/components/avatar/avatar.tsx
index c145b2ce2..7b4a576bc 100644
--- a/packages/raystack/components/avatar/avatar.tsx
+++ b/packages/raystack/components/avatar/avatar.tsx
@@ -12,7 +12,7 @@ import {
} from 'react';
import { radiusVariants } from '../../shared/radius';
import styles from './avatar.module.css';
-import { AVATAR_COLORS } from './utils';
+import type { AvatarColor } from './utils';
// Matches Base UI's AvatarRoot ImageLoadingStatus union.
type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error';
@@ -156,7 +156,7 @@ export interface AvatarProps
fallbackDelay?: AvatarPrimitive.Fallback.Props['delay'];
onLoadingStatusChange?: AvatarPrimitive.Image.Props['onLoadingStatusChange'];
variant?: 'solid' | 'soft';
- color?: AVATAR_COLORS;
+ color?: AvatarColor;
className?: string;
}
diff --git a/packages/raystack/components/avatar/index.tsx b/packages/raystack/components/avatar/index.tsx
index 80be713d5..eccf8984c 100644
--- a/packages/raystack/components/avatar/index.tsx
+++ b/packages/raystack/components/avatar/index.tsx
@@ -1,2 +1,7 @@
export { Avatar, AvatarGroup } from './avatar';
-export { getAvatarColor } from './utils';
+export {
+ AVATAR_COLORS,
+ type AvatarColor,
+ type GetAvatarColorOptions,
+ getAvatarColor
+} from './utils';
diff --git a/packages/raystack/components/avatar/utils.tsx b/packages/raystack/components/avatar/utils.tsx
index 7d96f9f2d..59fb72161 100644
--- a/packages/raystack/components/avatar/utils.tsx
+++ b/packages/raystack/components/avatar/utils.tsx
@@ -1,4 +1,4 @@
-export const COLORS = [
+export const AVATAR_COLORS = [
'indigo',
'orange',
'mint',
@@ -14,10 +14,27 @@ export const COLORS = [
'gold'
] as const;
-export type AVATAR_COLORS = (typeof COLORS)[number];
+export type AvatarColor = (typeof AVATAR_COLORS)[number];
-export function getAvatarColor(str: string): AVATAR_COLORS {
- const hash = str.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
- const index = hash % COLORS.length;
- return COLORS[index];
+export interface GetAvatarColorOptions {
+ /** Restricts the result to these colors. Order matters. If empty, all colors are used. */
+ palette?: readonly AvatarColor[];
+}
+
+export function getAvatarColor(
+ str: string,
+ { palette }: GetAvatarColorOptions = {}
+): AvatarColor {
+ const colors = palette?.length ? palette : AVATAR_COLORS;
+ // 32-bit FNV-1a
+ let hash = 0x811c9dc5;
+ for (let i = 0; i < str.length; i++) {
+ hash ^= str.charCodeAt(i);
+ hash = Math.imul(hash, 0x01000193);
+ }
+ // The lowest bit of FNV-1a is an XOR of each character's lowest bit, so it
+ // ignores order. Mixing the high bits in keeps a 2-color palette order-sensitive.
+ hash ^= hash >>> 16;
+ hash = Math.imul(hash, 0x45d9f3b) >>> 0;
+ return colors[hash % colors.length];
}
diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx
index 89156ac6e..c8018c342 100644
--- a/packages/raystack/index.tsx
+++ b/packages/raystack/index.tsx
@@ -5,7 +5,14 @@ export { Accordion } from './components/accordion';
export { AlertDialog } from './components/alert-dialog';
export { Amount, type AmountProps } from './components/amount';
export { AnnouncementBar } from './components/announcement-bar';
-export { Avatar, AvatarGroup, getAvatarColor } from './components/avatar';
+export {
+ AVATAR_COLORS,
+ Avatar,
+ type AvatarColor,
+ AvatarGroup,
+ type GetAvatarColorOptions,
+ getAvatarColor
+} from './components/avatar';
export { Badge } from './components/badge';
export { Box } from './components/box';
export { Breadcrumb } from './components/breadcrumb';