diff --git a/src/avatar/__snapshots__/avatar.test.tsx.snap b/src/avatar/__snapshots__/avatar.test.tsx.snap
deleted file mode 100644
index 565641450..000000000
--- a/src/avatar/__snapshots__/avatar.test.tsx.snap
+++ /dev/null
@@ -1,11 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Avatar renders a background image when avatarUrl is supplied 1`] = `
-
- HM
-
-`;
diff --git a/src/avatar/avatar-group.module.css b/src/avatar/avatar-group.module.css
new file mode 100644
index 000000000..f0d540749
--- /dev/null
+++ b/src/avatar/avatar-group.module.css
@@ -0,0 +1,272 @@
+/*
+ * AvatarGroup overlaps each child avatar over the previous one (negative
+ * margin-left). Because that partly covers the previous avatar, every
+ * non-first child uses a mask-image to cut a transparent notch where the
+ * previous sibling sits — letting it show through cleanly. Per-size custom
+ * properties (set by .avatarGroupSize-N) tune the overlap, mask thickness,
+ * and rounded-corner radius.
+ */
+
+.avatarGroup {
+ --reactist-avatar-group-count-overlay-background: rgba(0, 0, 0, 0.6);
+
+ /* Radius of the corner-cutter gradient (rounded shape): the previous
+ avatar's own corner radius, padded by the mask gap. */
+ --reactist-avatar-group-rounded-mask-radius: calc(
+ var(--reactist-avatar-group-rounded-radius) + var(--reactist-avatar-group-mask-thickness)
+ );
+
+ /* Horizontal slice on the left that holds the rounded corner cut-outs;
+ everything to its right is shown unmasked. */
+ --reactist-avatar-group-rounded-mask-width: calc(
+ var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask-thickness)
+ );
+
+ /* x-coordinate of the rounded corner-cutter's center. Sits one corner
+ radius to the left of where the overlap ends, so the curve lines up
+ with the previous avatar's rounded corner. */
+ --reactist-avatar-group-rounded-mask-corner-x: calc(
+ var(--reactist-avatar-group-overlap) - var(--reactist-avatar-group-rounded-radius)
+ );
+
+ /* x-coordinate of the previous avatar's center in this avatar's local
+ coordinate space. Negative: the previous sibling lies to the left.
+ Used to place the circle mask's notch. */
+ --reactist-avatar-group-previous-center-x: calc(
+ (var(--reactist-avatar-group-size) / 2) - var(--reactist-avatar-group-size) +
+ var(--reactist-avatar-group-overlap)
+ );
+
+ position: relative;
+}
+
+/*
+ * Per-size knobs. AvatarGroup applies exactly one of these classes based on
+ * its `size` prop, so one of them always wins.
+ *
+ * --reactist-avatar-group-size avatar width/height (must match the children's `size`)
+ * --reactist-avatar-group-overlap how far each avatar slides over the previous one
+ * --reactist-avatar-group-mask-thickness padding around the cut-out so the previous avatar's outline doesn't bleed through
+ * --reactist-avatar-group-rounded-radius corner radius for shape="rounded"; mirrors
+ * ROUNDED_AVATAR_RADIUS_BY_SIZE in src/avatar/utils.ts
+ */
+
+.avatarGroupSize-80 {
+ --reactist-avatar-group-size: 80px;
+ --reactist-avatar-group-overlap: 8px;
+ --reactist-avatar-group-mask-thickness: 3px;
+ --reactist-avatar-group-rounded-radius: 10px;
+}
+
+.avatarGroupSize-72 {
+ --reactist-avatar-group-size: 72px;
+ --reactist-avatar-group-overlap: 8px;
+ --reactist-avatar-group-mask-thickness: 3px;
+ --reactist-avatar-group-rounded-radius: 10px;
+}
+
+.avatarGroupSize-62 {
+ --reactist-avatar-group-size: 62px;
+ --reactist-avatar-group-overlap: 8px;
+ --reactist-avatar-group-mask-thickness: 3px;
+ --reactist-avatar-group-rounded-radius: 8.5px;
+}
+
+.avatarGroupSize-50 {
+ --reactist-avatar-group-size: 50px;
+ --reactist-avatar-group-overlap: 4px;
+ --reactist-avatar-group-mask-thickness: 3px;
+ --reactist-avatar-group-rounded-radius: 7px;
+}
+
+.avatarGroupSize-40 {
+ --reactist-avatar-group-size: 40px;
+ --reactist-avatar-group-overlap: 4px;
+ --reactist-avatar-group-mask-thickness: 3px;
+ --reactist-avatar-group-rounded-radius: 5.5px;
+}
+
+.avatarGroupSize-36 {
+ --reactist-avatar-group-size: 36px;
+ --reactist-avatar-group-overlap: 4px;
+ --reactist-avatar-group-mask-thickness: 2.5px;
+ --reactist-avatar-group-rounded-radius: 5px;
+}
+
+.avatarGroupSize-30 {
+ --reactist-avatar-group-size: 30px;
+ --reactist-avatar-group-overlap: 2px;
+ --reactist-avatar-group-mask-thickness: 2.5px;
+ --reactist-avatar-group-rounded-radius: 5px;
+}
+
+.avatarGroupSize-28 {
+ --reactist-avatar-group-size: 28px;
+ --reactist-avatar-group-overlap: 2px;
+ --reactist-avatar-group-mask-thickness: 2px;
+ --reactist-avatar-group-rounded-radius: 5px;
+}
+
+.avatarGroupSize-24 {
+ --reactist-avatar-group-size: 24px;
+ --reactist-avatar-group-overlap: 2px;
+ --reactist-avatar-group-mask-thickness: 2px;
+ --reactist-avatar-group-rounded-radius: 3.2px;
+}
+
+.avatarGroupSize-20 {
+ --reactist-avatar-group-size: 20px;
+ --reactist-avatar-group-overlap: 2px;
+ --reactist-avatar-group-mask-thickness: 2px;
+ --reactist-avatar-group-rounded-radius: 3px;
+}
+
+.avatarGroupSize-18 {
+ --reactist-avatar-group-size: 18px;
+ --reactist-avatar-group-overlap: 2px;
+ --reactist-avatar-group-mask-thickness: 1.5px;
+ --reactist-avatar-group-rounded-radius: 3px;
+}
+
+.avatarGroupSize-16 {
+ --reactist-avatar-group-size: 16px;
+ --reactist-avatar-group-overlap: 2px;
+ --reactist-avatar-group-mask-thickness: 1.25px;
+ --reactist-avatar-group-rounded-radius: 2px;
+}
+
+.avatarGroupSize-12 {
+ --reactist-avatar-group-size: 12px;
+ --reactist-avatar-group-overlap: 1px;
+ --reactist-avatar-group-mask-thickness: 1px;
+ --reactist-avatar-group-rounded-radius: 1.6px;
+}
+
+.avatarGroup > * {
+ flex-shrink: 0;
+}
+
+.avatarGroup > * + * {
+ margin-left: calc(-1 * var(--reactist-avatar-group-overlap));
+}
+
+/*
+ * Circle mask: one radial gradient centered on the previous avatar's center
+ * carves a transparent disc slightly larger than the avatar's outline
+ * (radius = size/2 + mask-thickness). The current avatar therefore shows
+ * nothing where the previous circle sits, letting it show through cleanly.
+ */
+.avatarGroupShape-circle > * + * {
+ -webkit-mask-image: radial-gradient(
+ circle
+ calc(
+ (var(--reactist-avatar-group-size) / 2) +
+ var(--reactist-avatar-group-mask-thickness)
+ )
+ at var(--reactist-avatar-group-previous-center-x) 50%,
+ transparent 99%,
+ #000 100%
+ );
+ mask-image: radial-gradient(
+ circle
+ calc(
+ (var(--reactist-avatar-group-size) / 2) +
+ var(--reactist-avatar-group-mask-thickness)
+ )
+ at var(--reactist-avatar-group-previous-center-x) 50%,
+ transparent 99%,
+ #000 100%
+ );
+}
+
+/*
+ * Rounded mask: built from three alpha-mask layers, listed top-to-bottom:
+ *
+ * 1. linear-gradient(#000), positioned `right top` at
+ * (100% - mask-width) × 100% — reveals the whole right portion of the
+ * avatar unmasked.
+ *
+ * 2. radial-gradient at the top-left, sized mask-width × rounded-radius —
+ * carves a concave arc matching the previous avatar's top-right
+ * rounded corner.
+ *
+ * 3. Same radial-gradient pinned to the bottom-left — same trick for the
+ * previous avatar's bottom-right corner.
+ *
+ * The left-middle strip (between the two corner cut-outs) is intentionally
+ * uncovered by any layer, so it stays transparent — that's where the body of
+ * the previous avatar shows through.
+ */
+.avatarGroupShape-rounded > * + * {
+ -webkit-mask-image:
+ linear-gradient(#000 0 0),
+ radial-gradient(
+ circle var(--reactist-avatar-group-rounded-mask-radius) at
+ var(--reactist-avatar-group-rounded-mask-corner-x)
+ var(--reactist-avatar-group-rounded-radius),
+ transparent 99%,
+ #000 100%
+ ),
+ radial-gradient(
+ circle var(--reactist-avatar-group-rounded-mask-radius) at
+ var(--reactist-avatar-group-rounded-mask-corner-x) 0,
+ transparent 99%,
+ #000 100%
+ );
+ mask-image:
+ linear-gradient(#000 0 0),
+ radial-gradient(
+ circle var(--reactist-avatar-group-rounded-mask-radius) at
+ var(--reactist-avatar-group-rounded-mask-corner-x)
+ var(--reactist-avatar-group-rounded-radius),
+ transparent 99%,
+ #000 100%
+ ),
+ radial-gradient(
+ circle var(--reactist-avatar-group-rounded-mask-radius) at
+ var(--reactist-avatar-group-rounded-mask-corner-x) 0,
+ transparent 99%,
+ #000 100%
+ );
+ -webkit-mask-position:
+ right top,
+ left top,
+ left bottom;
+ mask-position:
+ right top,
+ left top,
+ left bottom;
+ -webkit-mask-size:
+ calc(100% - var(--reactist-avatar-group-rounded-mask-width)) 100%,
+ var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius),
+ var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius);
+ mask-size:
+ calc(100% - var(--reactist-avatar-group-rounded-mask-width)) 100%,
+ var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius),
+ var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius);
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+}
+
+.avatarGroupCount {
+ position: absolute;
+ top: 0;
+ right: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--reactist-avatar-group-size);
+ height: var(--reactist-avatar-group-size);
+ border-radius: 50%;
+ background: var(--reactist-avatar-group-count-overlay-background);
+ color: var(--reactist-avatar-initials-color, var(--reactist-actionable-primary-idle-tint));
+ font-size: calc(var(--reactist-avatar-group-size) / 2);
+ font-weight: var(--reactist-font-weight-medium);
+ line-height: 1;
+ pointer-events: none;
+ user-select: none;
+}
+
+.avatarGroupShape-rounded > .avatarGroupCount {
+ border-radius: var(--reactist-avatar-group-rounded-radius);
+}
diff --git a/src/avatar/avatar-group.stories.tsx b/src/avatar/avatar-group.stories.tsx
new file mode 100644
index 000000000..2cfe9de0b
--- /dev/null
+++ b/src/avatar/avatar-group.stories.tsx
@@ -0,0 +1,309 @@
+import * as React from 'react'
+
+import { Avatar, AvatarGroup, Box, Inline, Stack, Text } from '../index'
+
+import type { Meta, StoryObj } from '@storybook/react-vite'
+
+const contributors = [
+ { name: 'pawel', githubUserId: '61894375' },
+ { name: 'craig', githubUserId: '1305500' },
+ { name: 'rui', githubUserId: '3165500' },
+ { name: 'ricardo', githubUserId: '96476' },
+ { name: 'scott', githubUserId: '25244878' },
+ { name: 'francesca', githubUserId: '1509326' },
+] as const
+
+const workspaceNames = ['Reactist', 'Todoist', 'Twist', 'Doist'] as const
+
+function getContributor(index: number): (typeof contributors)[number] {
+ return contributors[index % contributors.length]!
+}
+
+function getWorkspaceName(index: number): (typeof workspaceNames)[number] {
+ return workspaceNames[index % workspaceNames.length]!
+}
+
+function getGithubAvatarUrl(githubUserId: string, width: number) {
+ return `https://avatars.githubusercontent.com/u/${githubUserId}?s=${width}`
+}
+
+function getGithubSourceMap(githubUserId: string, width: number) {
+ return {
+ [width]: getGithubAvatarUrl(githubUserId, width),
+ [width * 2]: getGithubAvatarUrl(githubUserId, width * 2),
+ [width * 3]: getGithubAvatarUrl(githubUserId, width * 3),
+ }
+}
+
+function StoryLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function StorySection({
+ title,
+ description,
+ children,
+}: {
+ title: string
+ description?: string
+ children: React.ReactNode
+}) {
+ return (
+
+
+ {title}
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ {children}
+
+ )
+}
+
+function AvatarExample({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+ {label}
+
+
+
+ )
+}
+
+function UserAvatar({
+ contributor,
+ size,
+}: {
+ contributor: (typeof contributors)[number]
+ size: React.ComponentProps['size']
+}) {
+ return (
+
+ )
+}
+
+function WorkspaceAvatar({
+ name,
+ size,
+}: {
+ name: string
+ size: React.ComponentProps['size']
+}) {
+ return
+}
+
+function CustomOverlayStyle() {
+ return (
+
+ )
+}
+
+const meta = {
+ title: 'Components/Avatar/AvatarGroup',
+ component: AvatarGroup,
+ parameters: {
+ badges: ['accessible'],
+ },
+} satisfies Meta
+
+export default meta
+
+type Story = StoryObj
+
+export const People = {
+ render: () => (
+
+
+
+
+
+ {contributors.slice(0, 5).map((contributor) => (
+
+ ))}
+
+
+
+
+ {contributors.slice(1, 4).map((contributor) => (
+
+ ))}
+
+
+
+
+ {contributors.slice(2, 5).map((contributor) => (
+
+ ))}
+
+
+
+
+
+ ),
+} satisfies Story
+
+export const Workspaces = {
+ render: () => (
+
+
+
+
+
+ {workspaceNames.map((name) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {workspaceNames.slice(0, 3).map((name) => (
+
+ ))}
+
+
+
+
+
+ ),
+} satisfies Story
+
+export const Sizes = {
+ render: () => (
+
+
+
+ {([80, 62, 50, 36, 24, 18, 12] as const).map((size, index) => (
+
+
+ {[0, 1, 2].map((offset) => (
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+ {([80, 62, 50, 36, 24, 18, 12] as const).map((size, index) => (
+
+
+ {[0, 1, 2].map((offset) => (
+
+ ))}
+
+
+ ))}
+
+
+
+ ),
+} satisfies Story
+
+export const CountOverlay = {
+ render: () => (
+
+
+
+
+
+
+
+ {contributors.slice(0, 4).map((contributor) => (
+
+ ))}
+
+
+
+
+ {contributors.slice(1, 5).map((contributor) => (
+
+ ))}
+
+
+
+
+ {workspaceNames.map((name) => (
+
+ ))}
+
+
+
+
+
+ ),
+} satisfies Story
diff --git a/src/avatar/avatar-group.test.tsx b/src/avatar/avatar-group.test.tsx
new file mode 100644
index 000000000..965afc49d
--- /dev/null
+++ b/src/avatar/avatar-group.test.tsx
@@ -0,0 +1,106 @@
+import * as React from 'react'
+
+import { render, screen } from '@testing-library/react'
+import { axe } from 'jest-axe'
+
+import { Avatar } from './avatar'
+import { AvatarGroup } from './avatar-group'
+
+describe('AvatarGroup', () => {
+ it('renders direct Avatar children without wrappers', () => {
+ render(
+
+
+
+ ,
+ )
+
+ expect(screen.getByTestId('group')).toContainElement(screen.getByTestId('first'))
+ expect(screen.getByTestId('group')).toContainElement(screen.getByTestId('second'))
+ expect(screen.getByTestId('first').parentElement).toBe(screen.getByTestId('group'))
+ expect(screen.getByTestId('second').parentElement).toBe(screen.getByTestId('group'))
+ })
+
+ it('hides the count overlay from assistive tech', () => {
+ render(
+
+
+
+ ,
+ )
+
+ expect(screen.getByText('3')).toHaveAttribute('aria-hidden', 'true')
+ })
+
+ it('omits the count overlay when count is not positive', () => {
+ render(
+
+
+
+ ,
+ )
+
+ expect(screen.queryByText(/^\+/)).not.toBeInTheDocument()
+ })
+
+ it('omits the count overlay when count is not provided', () => {
+ render(
+
+
+
+ ,
+ )
+
+ expect(screen.queryByText(/^\+/)).not.toBeInTheDocument()
+ })
+
+ it('renders the count overlay alongside a single avatar', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('4')).toBeInTheDocument()
+ expect(screen.getByRole('img', { name: 'Jane Doe' })).toBeInTheDocument()
+ })
+
+ it('applies the escape hatch class name', () => {
+ render(
+
+
+
+ ,
+ )
+
+ expect(screen.getByTestId('group')).toHaveClass('custom-group')
+ })
+
+ describe('a11y', () => {
+ it('renders with no a11y violations', async () => {
+ const { container } = render(
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >,
+ )
+ const results = await axe(container)
+
+ expect(results).toHaveNoViolations()
+ })
+ })
+})
diff --git a/src/avatar/avatar-group.tsx b/src/avatar/avatar-group.tsx
new file mode 100644
index 000000000..6aa533c0c
--- /dev/null
+++ b/src/avatar/avatar-group.tsx
@@ -0,0 +1,112 @@
+import * as React from 'react'
+
+import classNames from 'classnames'
+
+import { Box } from '../box'
+import { polymorphicComponent } from '../utils/polymorphism'
+
+import styles from './avatar-group.module.css'
+
+import type { ObfuscatedClassName } from '../utils/common-types'
+import type { PolymorphicComponentProps } from '../utils/polymorphism'
+import type { AvatarShape, AvatarSize } from './utils'
+
+/**
+ * Props for the `AvatarGroup` component.
+ */
+type AvatarGroupOwnProps = ObfuscatedClassName & {
+ /**
+ * The rendered avatar size, in CSS pixels.
+ *
+ * Direct child Avatar components should use the same size.
+ */
+ size: AvatarSize
+
+ /**
+ * The grouped avatar shape.
+ *
+ * Direct child Avatar components should use the same shape.
+ *
+ * @default 'circle'
+ */
+ shape?: AvatarShape
+
+ /**
+ * Additional people not shown in the group. When positive, rendered as a
+ * decorative `N` overlay on top of the final avatar; hidden from
+ * assistive tech.
+ */
+ count?: number
+
+ /**
+ * Grouped Avatar children.
+ */
+ children: React.ReactNode
+
+ /**
+ * Test identifier applied to the avatar group root element.
+ */
+ 'data-testid'?: string
+
+ /**
+ * AvatarGroup owns its root sizing styles. Use `exceptionallySetClassName` for the styling
+ * escape hatch.
+ */
+ style?: never
+}
+
+type AvatarGroupProps = PolymorphicComponentProps<
+ ComponentType,
+ AvatarGroupOwnProps,
+ 'omitClassName'
+>
+
+/**
+ * Displays a row of overlapping Avatar children with an optional count overlay
+ * on the final avatar.
+ */
+const AvatarGroup = polymorphicComponent<'div', AvatarGroupOwnProps, 'omitClassName'>(
+ function AvatarGroup(
+ {
+ as,
+ size,
+ shape = 'circle',
+ count,
+ children,
+ exceptionallySetClassName,
+ 'data-testid': testId,
+ ...restProps
+ },
+ ref,
+ ) {
+ const overflowCount = count && count > 0 ? count : null
+
+ return (
+
+ {children}
+ {overflowCount !== null ? (
+
+ {overflowCount}
+
+ ) : null}
+
+ )
+ },
+)
+
+export { AvatarGroup }
+export type { AvatarGroupProps }
diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx
new file mode 100644
index 000000000..3bf4998f2
--- /dev/null
+++ b/src/avatar/avatar.mdx
@@ -0,0 +1,212 @@
+import {
+ Canvas,
+ ColorItem,
+ ColorPalette,
+ Controls,
+ Markdown,
+ Meta,
+ Subtitle,
+ Title,
+} from '@storybook/addon-docs/blocks'
+
+import * as AvatarStories from './avatar.stories'
+import * as AvatarGroupStories from './avatar-group.stories'
+
+
+
+
+
+Image, initials, and empty-state avatar primitive.
+
+## Basic usage
+
+Use `Avatar` for people by default. Pass `size`, `name`, and an optional
+`image`; `name` supplies the default accessible label, the initials fallback,
+and the deterministic meta color used when initials render.
+
+
+
+## Avatar groups
+
+Use `AvatarGroup` when a compact surface represents several people. Pass the
+same `size` to the group and its direct `Avatar` children. The optional `count`
+is rendered as a decorative `N` visual on top of the final avatar and is
+hidden from assistive tech. When the group represents a labeled entity (a
+button, link, or labeled region), supply an `aria-label` on the group that
+conveys the count to assistive tech.
+
+
+
+## Migrating from the legacy API
+
+The previous Avatar API accepted `user`, `avatarUrl`, `colorList`, string or
+responsive `size` values, and a deprecated `className`. The current API uses
+direct identity props instead:
+
+{`
+| Legacy prop | Current API |
+| ------------------------------ | ------------------------------------------------------------------------ |
+| \`user.name\` | \`name\` |
+| \`avatarUrl\` | \`image\` |
+| \`user.email\` | No replacement. Email is no longer used for initials or color selection. |
+| \`colorList\` | Customize the CSS custom properties listed below. |
+| \`size="l"\` or responsive sizes | Pass one supported numeric CSS-pixel \`size\`. |
+| \`className\` | \`exceptionallySetClassName\` |
+`}
+
+```tsx
+
+```
+
+## Initials fallback
+
+When `image` is not supplied, cannot be resolved, or every responsive image
+candidate fails, Avatar falls back to initials derived from `name`. Names are
+normalized before initials are generated.
+
+
+
+## Workspace avatars
+
+Use `shape="rounded"` for workspace-like entities. Product code can wrap
+Avatar with a small convention component when a surface always represents the
+same kind of entity.
+
+
+
+## Image sources
+
+Pass a string for a single image URL, or a source map keyed by intrinsic image
+width. Source maps render native `srcSet` width descriptors and a `sizes` hint
+based on the selected avatar size.
+
+
+
+## Sizes
+
+Avatar supports a fixed set of CSS pixel sizes. Use one of the supported
+numeric values instead of styling the avatar dimensions from the outside.
+
+
+
+## Accessibility
+
+Images default to `name` for alt text. Pass `alt` when the visual needs a more
+specific label, and pass `alt=""` when the avatar is decorative.
+
+
+
+## Playground
+
+Use the controls to inspect the component API and common image/name
+combinations.
+
+
+
+### API
+
+
+
+## Custom properties
+
+The following CSS custom properties are available to customize the avatar
+component appearance. The values shown below are the default values.
+
+
+
+### Customizable properties
+
+#### Avatar colors
+
+
+
+
+
+
+
+
+#### Avatar meta colors
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+### Component-owned variables
+
+Avatar sets these variables at render time from the `size`, `shape`, and
+`name` props. They are listed for completeness, but consumers should prefer
+the component props instead of overriding them directly.
+
+```css
+.avatar {
+ --reactist-avatar-size: 36px;
+ --reactist-avatar-rounded-radius: 5px;
+}
+
+.avatarGroup {
+ --reactist-avatar-group-size: 36px;
+ --reactist-avatar-group-overlap: 4px;
+ --reactist-avatar-group-mask-thickness: 2.5px;
+ --reactist-avatar-group-rounded-radius: 5px;
+}
+```
+
+## What the consumer owns
+
+- **Identity data** — choose the `name`, `image`, and any custom `alt` text.
+- **Source selection** — provide either one URL or a width-keyed source map.
+- **Entity convention** — choose `shape="circle"` for people and
+ `shape="rounded"` for workspace-like entities.
+- **Decorative usage** — pass `alt=""` when surrounding UI already names the
+ represented entity.
+- **Persistence and fetching** — Avatar does not load, cache, or persist remote
+ user/workspace data.
+
+## Accessibility
+
+- `name` becomes the default image `alt` text and initials `aria-label`.
+- `alt` overrides the accessible label for both image and initials rendering.
+- `alt=""` marks image and initials avatars as decorative.
+- An avatar with no `name` and no `image` renders as an empty decorative visual.
diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css
index 4c97af2bb..acaec40d6 100644
--- a/src/avatar/avatar.module.css
+++ b/src/avatar/avatar.module.css
@@ -1,128 +1,191 @@
:root {
- --reactist-avatar-size-xxsmall: 16px;
- --reactist-avatar-size-xsmall: 20px;
- --reactist-avatar-size-small: 30px;
- --reactist-avatar-size-medium: 32px;
- --reactist-avatar-size-large: 34px;
- --reactist-avatar-size-xlarge: 48px;
- --reactist-avatar-size-xxlarge: 70px;
- --reactist-avatar-size-xxxlarge: 100px;
-
- --reactist-avatar-size: var(--reactist-avatar-size-large);
+ --reactist-avatar-initials-color: var(--reactist-actionable-primary-idle-tint);
+ --reactist-avatar-border-tint: #0000001a;
+ --reactist-avatar-empty-fill: var(--reactist-framework-fill-crest);
+
+ --reactist-avatar-meta-0-fill: #b8255f;
+ --reactist-avatar-meta-0-on-idle-tint: var(--reactist-avatar-initials-color);
+ --reactist-avatar-meta-1-fill: #dc4c3e;
+ --reactist-avatar-meta-1-on-idle-tint: var(--reactist-avatar-initials-color);
+ --reactist-avatar-meta-2-fill: #f48318;
+ --reactist-avatar-meta-2-on-idle-tint: var(--reactist-avatar-initials-color);
+ --reactist-avatar-meta-3-fill: #fecf05;
+ --reactist-avatar-meta-3-on-idle-tint: #202020;
+ --reactist-avatar-meta-4-fill: #aeb83a;
+ --reactist-avatar-meta-4-on-idle-tint: var(--reactist-avatar-initials-color);
+ --reactist-avatar-meta-5-fill: #7ecc48;
+ --reactist-avatar-meta-5-on-idle-tint: var(--reactist-avatar-initials-color);
+ --reactist-avatar-meta-6-fill: #369307;
+ --reactist-avatar-meta-6-on-idle-tint: var(--reactist-avatar-initials-color);
+ --reactist-avatar-meta-7-fill: #52ccb8;
+ --reactist-avatar-meta-7-on-idle-tint: var(--reactist-avatar-initials-color);
+ --reactist-avatar-meta-8-fill: #148fad;
+ --reactist-avatar-meta-8-on-idle-tint: var(--reactist-avatar-initials-color);
+ --reactist-avatar-meta-9-fill: #3ab9e2;
+ --reactist-avatar-meta-9-on-idle-tint: #202020;
+ --reactist-avatar-meta-10-fill: #96c3eb;
+ --reactist-avatar-meta-10-on-idle-tint: var(--reactist-avatar-initials-color);
+ --reactist-avatar-meta-11-fill: #2a67e2;
+ --reactist-avatar-meta-11-on-idle-tint: var(--reactist-avatar-initials-color);
+ --reactist-avatar-meta-12-fill: #692ec2;
+ --reactist-avatar-meta-12-on-idle-tint: var(--reactist-avatar-initials-color);
+ --reactist-avatar-meta-13-fill: #ac30cc;
+ --reactist-avatar-meta-13-on-idle-tint: var(--reactist-avatar-initials-color);
+ --reactist-avatar-meta-14-fill: #eb96c8;
+ --reactist-avatar-meta-14-on-idle-tint: var(--reactist-avatar-initials-color);
+ --reactist-avatar-meta-15-fill: #e05095;
+ --reactist-avatar-meta-15-on-idle-tint: var(--reactist-avatar-initials-color);
+ --reactist-avatar-meta-16-fill: #c9766f;
+ --reactist-avatar-meta-16-on-idle-tint: var(--reactist-avatar-initials-color);
+ --reactist-avatar-meta-17-fill: #808080;
+ --reactist-avatar-meta-17-on-idle-tint: var(--reactist-avatar-initials-color);
+ --reactist-avatar-meta-18-fill: #999999;
+ --reactist-avatar-meta-18-on-idle-tint: var(--reactist-avatar-initials-color);
+ --reactist-avatar-meta-19-fill: #ccae96;
+ --reactist-avatar-meta-19-on-idle-tint: var(--reactist-avatar-initials-color);
}
.avatar {
- flex-shrink: 0;
- background-position: center;
- color: white;
- text-align: center;
- border-radius: 50%;
+ --reactist-avatar-size: 36px;
+ --reactist-avatar-rounded-radius: 5px;
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-0-fill);
+ background-color: var(--reactist-avatar-empty-fill);
width: var(--reactist-avatar-size);
height: var(--reactist-avatar-size);
- line-height: var(--reactist-avatar-size);
- background-size: var(--reactist-avatar-size);
- font-size: calc(var(--reactist-avatar-size) / 2);
+
+ outline: 2px solid var(--reactist-avatar-border-tint);
+ outline-offset: -2px;
+}
+
+.avatar:has(.initials) {
+ background-color: var(--reactist-avatar-meta-fill);
}
-.size-xxs {
- --reactist-avatar-size: var(--reactist-avatar-size-xxsmall);
+.meta-color-0 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-0-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-0-on-idle-tint);
}
-.size-xs {
- --reactist-avatar-size: var(--reactist-avatar-size-xsmall);
+.meta-color-1 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-1-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-1-on-idle-tint);
}
-.size-s {
- --reactist-avatar-size: var(--reactist-avatar-size-small);
+.meta-color-2 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-2-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-2-on-idle-tint);
}
-.size-m {
- --reactist-avatar-size: var(--reactist-avatar-size-medium);
+.meta-color-3 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-3-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-3-on-idle-tint);
}
-.size-l {
- --reactist-avatar-size: var(--reactist-avatar-size-large);
+.meta-color-4 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-4-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-4-on-idle-tint);
}
-.size-xl {
- --reactist-avatar-size: var(--reactist-avatar-size-xlarge);
+.meta-color-5 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-5-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-5-on-idle-tint);
}
-.size-xxl {
- --reactist-avatar-size: var(--reactist-avatar-size-xxlarge);
+.meta-color-6 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-6-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-6-on-idle-tint);
}
-.size-xxxl {
- --reactist-avatar-size: var(--reactist-avatar-size-xxxlarge);
+.meta-color-7 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-7-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-7-on-idle-tint);
}
-/* avatar size for tablet */
-@media (min-width: 768px /* --reactist-breakpoint-tablet */) {
- .tablet-size-xxs {
- --reactist-avatar-size: var(--reactist-avatar-size-xxsmall);
- }
+.meta-color-8 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-8-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-8-on-idle-tint);
+}
+
+.meta-color-9 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-9-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-9-on-idle-tint);
+}
- .tablet-size-xs {
- --reactist-avatar-size: var(--reactist-avatar-size-xsmall);
- }
+.meta-color-10 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-10-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-10-on-idle-tint);
+}
- .tablet-size-s {
- --reactist-avatar-size: var(--reactist-avatar-size-small);
- }
+.meta-color-11 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-11-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-11-on-idle-tint);
+}
- .tablet-size-m {
- --reactist-avatar-size: var(--reactist-avatar-size-medium);
- }
+.meta-color-12 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-12-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-12-on-idle-tint);
+}
- .tablet-size-l {
- --reactist-avatar-size: var(--reactist-avatar-size-large);
- }
+.meta-color-13 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-13-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-13-on-idle-tint);
+}
- .tablet-size-xl {
- --reactist-avatar-size: var(--reactist-avatar-size-xlarge);
- }
+.meta-color-14 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-14-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-14-on-idle-tint);
+}
- .tablet-size-xxl {
- --reactist-avatar-size: var(--reactist-avatar-size-xxlarge);
- }
+.meta-color-15 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-15-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-15-on-idle-tint);
+}
- .tablet-size-xxxl {
- --reactist-avatar-size: var(--reactist-avatar-size-xxxlarge);
- }
+.meta-color-16 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-16-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-16-on-idle-tint);
}
-/* avatar size for desktop */
-@media (min-width: 992px /* --reactist-breakpoint-desktop */) {
- .desktop-size-xxs {
- --reactist-avatar-size: var(--reactist-avatar-size-xxsmall);
- }
+.meta-color-17 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-17-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-17-on-idle-tint);
+}
- .desktop-size-xs {
- --reactist-avatar-size: var(--reactist-avatar-size-xsmall);
- }
+.meta-color-18 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-18-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-18-on-idle-tint);
+}
- .desktop-size-s {
- --reactist-avatar-size: var(--reactist-avatar-size-small);
- }
+.meta-color-19 {
+ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-19-fill);
+ --reactist-avatar-initials-color: var(--reactist-avatar-meta-19-on-idle-tint);
+}
- .desktop-size-m {
- --reactist-avatar-size: var(--reactist-avatar-size-medium);
- }
+.empty {
+ background-color: var(--reactist-avatar-empty-fill);
+}
- .desktop-size-l {
- --reactist-avatar-size: var(--reactist-avatar-size-large);
- }
+.shape-circle {
+ border-radius: 50%;
+}
- .desktop-size-xl {
- --reactist-avatar-size: var(--reactist-avatar-size-xlarge);
- }
+.shape-rounded {
+ border-radius: var(--reactist-avatar-rounded-radius);
+}
- .desktop-size-xxl {
- --reactist-avatar-size: var(--reactist-avatar-size-xxlarge);
- }
+.image {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border-radius: inherit;
+ object-fit: cover;
+}
- .desktop-size-xxxl {
- --reactist-avatar-size: var(--reactist-avatar-size-xxxlarge);
- }
+.initials {
+ color: var(--reactist-avatar-initials-color);
+ font-size: calc(var(--reactist-avatar-size) / 2);
+ font-weight: var(--reactist-font-weight-medium);
+ line-height: 1;
+ user-select: none;
}
diff --git a/src/avatar/avatar.stories.tsx b/src/avatar/avatar.stories.tsx
new file mode 100644
index 000000000..720595d14
--- /dev/null
+++ b/src/avatar/avatar.stories.tsx
@@ -0,0 +1,461 @@
+import * as React from 'react'
+
+import { Avatar, Box, Inline, Stack, Text } from '../index'
+
+import { AVATAR_SIZES, getAvatarMetaColorIndex } from './utils'
+
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import type { AvatarProps } from './avatar'
+
+const sizes = AVATAR_SIZES
+
+const contributors = [
+ {
+ name: 'doistbot',
+ githubUserId: '37183429',
+ },
+ {
+ name: 'pawel',
+ githubUserId: '61894375',
+ },
+ {
+ name: 'craig',
+ githubUserId: '1305500',
+ },
+ {
+ name: 'rui',
+ githubUserId: '3165500',
+ },
+ {
+ name: 'ricardo',
+ githubUserId: '96476',
+ },
+ {
+ name: 'scott',
+ githubUserId: '25244878',
+ },
+ {
+ name: 'francesca',
+ githubUserId: '1509326',
+ },
+ {
+ name: 'henning',
+ githubUserId: '6048870',
+ },
+] as const
+
+const initialsExamples = [
+ {
+ label: 'Single part',
+ name: 'doistbot',
+ },
+ {
+ label: 'First + last',
+ name: 'Pawel Grimm',
+ },
+ {
+ label: 'Whitespace',
+ name: ' craig reactist ',
+ },
+ {
+ label: 'Unicode',
+ name: 'Åsa Núñez',
+ },
+] as const
+
+const metaColorExamples = [
+ 'Ada 28',
+ 'Ben 15',
+ 'Cam 38',
+ 'Dee 3',
+ 'Eli 2',
+ 'Flo 17',
+ 'Gia 3',
+ 'Hao 27',
+ 'Ivy 26',
+ 'Jon 4',
+ 'Kai 3',
+ 'Lia 3',
+ 'Max 8',
+ 'Nia 3',
+ 'Oli 2',
+ 'Pia 3',
+ 'Quin 3',
+ 'Rae 7',
+ 'Sol 6',
+ 'Tia 3',
+].map((name) => ({ name, index: getAvatarMetaColorIndex(name) }))
+
+const playgroundImages = {
+ None: '',
+ 'doistbot, 60px': getGithubAvatarUrl('37183429', 60),
+ 'pawel, 72px': getGithubAvatarUrl('61894375', 72),
+ 'craig, 96px': getGithubAvatarUrl('1305500', 96),
+ 'rui, 120px': getGithubAvatarUrl('3165500', 120),
+ 'ricardo, 144px': getGithubAvatarUrl('96476', 144),
+ 'scott, 180px': getGithubAvatarUrl('25244878', 180),
+ 'francesca, 216px': getGithubAvatarUrl('1509326', 216),
+ 'henning, 240px': getGithubAvatarUrl('6048870', 240),
+ 'Missing image': '/missing-avatar-playground.png',
+} as const
+
+function getContributor(index: number) {
+ return contributors[index % contributors.length]
+}
+
+function getGithubAvatarUrl(githubUserId: string, width: number) {
+ return `https://avatars.githubusercontent.com/u/${githubUserId}?s=${width}`
+}
+
+function getGithubSourceMap(githubUserId: string, width: number) {
+ return {
+ [width]: getGithubAvatarUrl(githubUserId, width),
+ [width * 2]: getGithubAvatarUrl(githubUserId, width * 2),
+ [width * 3]: getGithubAvatarUrl(githubUserId, width * 3),
+ }
+}
+
+function StoryLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function StorySection({
+ title,
+ description,
+ children,
+}: {
+ title: string
+ description?: string
+ children: React.ReactNode
+}) {
+ return (
+
+
+ {title}
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ {children}
+
+ )
+}
+
+function AvatarExample({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+ {label}
+
+
+
+ )
+}
+
+function UserAvatar(props: Omit) {
+ return
+}
+
+function WorkspaceAvatarExample(props: Omit) {
+ return
+}
+
+function AvatarColorExample({ index, name }: { index: number; name: string }) {
+ return (
+
+
+
+ )
+}
+
+type PlaygroundImage = keyof typeof playgroundImages
+
+type PlaygroundArgs = Omit & {
+ image?: PlaygroundImage
+}
+
+const meta = {
+ title: 'Components/Avatar',
+ component: Avatar,
+ parameters: {
+ badges: ['accessible'],
+ },
+} satisfies Meta
+
+export default meta
+
+type Story = StoryObj
+type PlaygroundStory = StoryObj
+
+export const Default = {
+ render: () => (
+
+
+
+ {contributors.slice(1, 6).map((contributor) => (
+
+
+
+ ))}
+
+
+
+ ),
+} satisfies Story
+
+export const InitialsFallback = {
+ render: () => (
+
+
+
+ {initialsExamples.map(({ label, name }) => (
+
+
+
+ ))}
+
+
+
+
+
+
+ ),
+} satisfies Story
+
+export const WorkspaceAvatar = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+} satisfies Story
+
+export const ImageSources = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+} satisfies Story
+
+export const Sizes = {
+ render: () => (
+
+
+
+ {sizes.map((size, index) => {
+ const contributor = getContributor(index)
+
+ return (
+
+
+
+ )
+ })}
+
+
+
+
+
+ {sizes.map((size, index) => {
+ const contributor = getContributor(index)
+
+ return (
+
+
+
+ )
+ })}
+
+
+
+ ),
+} satisfies Story
+
+export const Accessibility = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+} satisfies Story
+
+export const MetaColors = {
+ render: () => (
+
+
+
+ {metaColorExamples.map(({ index, name }) => (
+
+ ))}
+
+
+
+ ),
+} satisfies Story
+
+export const Playground = {
+ args: {
+ size: 36,
+ shape: 'circle',
+ name: contributors[1].name,
+ image: 'pawel, 72px',
+ alt: undefined,
+ },
+ argTypes: {
+ size: {
+ control: { type: 'select' },
+ options: sizes,
+ },
+ shape: {
+ control: { type: 'select' },
+ options: ['circle', 'rounded'],
+ },
+ name: {
+ control: {
+ type: 'text',
+ },
+ },
+ image: {
+ options: Object.keys(playgroundImages),
+ control: {
+ type: 'select',
+ },
+ },
+ alt: {
+ control: {
+ type: 'text',
+ },
+ },
+ },
+ render: (args: PlaygroundArgs) => (
+
+
+
+ ),
+} satisfies PlaygroundStory
diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx
index 68409eafb..e78137b30 100644
--- a/src/avatar/avatar.test.tsx
+++ b/src/avatar/avatar.test.tsx
@@ -1,64 +1,305 @@
import * as React from 'react'
-import { render, screen } from '@testing-library/react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { axe } from 'jest-axe'
import { Avatar } from './avatar'
describe('Avatar', () => {
- it('renders a background image when avatarUrl is supplied', () => {
- render(getAvatar({ avatarUrl: 'https://foo.bar/com.png' }))
+ function failCurrentAvatarImage(currentSrc: string) {
+ const image = screen.getByRole('img', { name: 'Jane Doe' })
+ Object.defineProperty(image, 'currentSrc', {
+ configurable: true,
+ value: currentSrc,
+ })
+ fireEvent.error(image)
+ }
+
+ it('renders a string image URL', () => {
+ render()
+
+ expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute('src', 'avatar.png')
+ expect(screen.getByTestId('avatar')).toHaveStyle({
+ '--reactist-avatar-size': '36px',
+ })
+ })
+
+ it('does not apply meta color classes while rendering an image', () => {
+ render()
+
+ expect(
+ Array.from(screen.getByTestId('avatar').classList).some((className) =>
+ className.startsWith('meta-color-'),
+ ),
+ ).toBe(false)
+ })
+
+ it('renders a source-map image URL with native responsive image hints', () => {
+ render(
+ ,
+ )
+
+ const image = screen.getByRole('img', { name: 'Jane Doe' })
+ expect(image).toHaveAttribute('src', 'avatar-144.png')
+ expect(image).toHaveAttribute(
+ 'srcset',
+ 'avatar-36.png 36w, avatar-72.png 72w, avatar-144.png 144w',
+ )
+ expect(image).toHaveAttribute('sizes', '36px')
+ })
+
+ it('falls back to initials when no image is provided', () => {
+ render()
+
+ expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD')
+ expect(screen.getByTestId('avatar')).toHaveClass('meta-color-0')
+ })
+
+ it('applies the deterministic meta color class for the avatar name', () => {
+ render()
+
+ expect(screen.getByTestId('avatar')).toHaveClass('meta-color-9')
+ })
+
+ it('falls back to initials when image source map is empty', () => {
+ render()
+
+ expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD')
+ })
+
+ it('falls back to initials when the image fails to load', () => {
+ render()
+
+ fireEvent.error(screen.getByRole('img', { name: 'Jane Doe' }))
+
+ expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD')
+ })
+
+ it('allows a new image to load after a failed image changes', () => {
+ const { rerender } = render()
+
+ fireEvent.error(screen.getByRole('img', { name: 'Jane Doe' }))
+ expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD')
+
+ rerender()
+
+ expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute('src', 'avatar.png')
+ })
+
+ it('removes a failed source-map candidate and retries with the remaining candidates', () => {
+ render(
+ ,
+ )
+
+ failCurrentAvatarImage('avatar-144.png')
+
+ const image = screen.getByRole('img', { name: 'Jane Doe' })
+ expect(image).toHaveAttribute('src', 'avatar-72.png')
+ expect(image).toHaveAttribute('srcset', 'avatar-36.png 36w, avatar-72.png 72w')
+ expect(image).toHaveAttribute('sizes', '36px')
+ })
+
+ it('removes the selected source-map candidate when it is not the fallback src', () => {
+ render(
+ ,
+ )
+
+ failCurrentAvatarImage(new URL('avatar-72.png', document.baseURI).href)
+
+ const image = screen.getByRole('img', { name: 'Jane Doe' })
+ expect(image).toHaveAttribute('src', 'avatar-144.png')
+ expect(image).toHaveAttribute('srcset', 'avatar-36.png 36w, avatar-144.png 144w')
+ expect(image).toHaveAttribute('sizes', '36px')
+ })
- const avatar = screen.getByTestId('avatar')
+ it('keeps filtered source-map candidates when only the avatar size changes', () => {
+ const image = {
+ 36: 'avatar-36.png',
+ 72: 'avatar-72.png',
+ 144: 'avatar-144.png',
+ }
+ const { rerender } = render()
- expect(avatar).toMatchSnapshot()
+ failCurrentAvatarImage('avatar-144.png')
+
+ rerender()
+
+ const retriedImage = screen.getByRole('img', { name: 'Jane Doe' })
+ expect(retriedImage).toHaveAttribute('src', 'avatar-72.png')
+ expect(retriedImage).toHaveAttribute('srcset', 'avatar-36.png 36w, avatar-72.png 72w')
+ expect(retriedImage).toHaveAttribute('sizes', '72px')
})
- it('renders initials of user name when avatarUrl is not supplied', () => {
- render(getAvatar())
+ it('falls back to initials when every source-map candidate fails', () => {
+ render(
+ ,
+ )
- const avatar = screen.getByTestId('avatar')
+ failCurrentAvatarImage('avatar-72.png')
+ failCurrentAvatarImage('avatar-36.png')
- expect(avatar).toHaveTextContent('HM')
+ expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD')
})
- it('renders initials of user email when avatarUrl is not supplied', () => {
- render(getAvatar({ user: { email: 'henning@doist.com' } }))
+ it('retries a failed image when the same image is provided after being removed', () => {
+ const { rerender } = render()
+
+ fireEvent.error(screen.getByRole('img', { name: 'Jane Doe' }))
+ expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD')
- const avatar = screen.getByTestId('avatar')
+ rerender()
+ expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD')
- expect(avatar).toHaveTextContent('H')
+ rerender()
+
+ expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute('src', 'missing.png')
+ })
+
+ it('renders a neutral empty avatar when no name or image is provided', () => {
+ render()
+
+ expect(screen.getByTestId('avatar')).toHaveClass('empty')
+ expect(screen.getByTestId('avatar')).toHaveTextContent('')
})
- it('supports responsive values', () => {
+ it('can render the root as a different element', () => {
+ render()
+
+ expect(screen.getByTestId('avatar').tagName).toBe('SECTION')
+ })
+
+ it('derives the root ref type from the element rendered with as', () => {
+ const anchorRef = React.createRef()
+ const buttonRef = React.createRef()
+
render(
- getAvatar({
- size: {
- mobile: 's',
- desktop: 'xl',
- tablet: 'xxl',
- },
- }),
+ ,
)
- const avatar = screen.getByTestId('avatar')
- expect(avatar).toHaveClass('size-s')
- expect(avatar).toHaveClass('desktop-size-xl')
- expect(avatar).toHaveClass('tablet-size-xxl')
+ expect(anchorRef.current).toBe(screen.getByTestId('avatar'))
+
+ const invalidRefElement = (
+ // @ts-expect-error refs must match the element selected with as
+
+ )
+ expect(invalidRefElement).toBeTruthy()
+ })
+
+ it('supports rounded shape with size-aware radius', () => {
+ render()
+
+ expect(screen.getByTestId('avatar')).toHaveClass('shape-rounded')
+ expect(screen.getByTestId('avatar')).toHaveStyle({
+ '--reactist-avatar-rounded-radius': '7px',
+ })
+ })
+
+ it('defaults to circle shape', () => {
+ render()
+
+ expect(screen.getByTestId('avatar')).toHaveClass('shape-circle')
+ })
+
+ it('uses custom alt text as the accessible label', () => {
+ render()
+
+ expect(screen.getByRole('img', { name: 'Account avatar' })).toBeInTheDocument()
+ })
+
+ it('uses custom alt text as the accessible label for initials avatars', () => {
+ render()
+
+ expect(screen.getByRole('img', { name: 'Account avatar' })).toHaveTextContent('JD')
+ })
+
+ it('normalizes the default accessible label before deciding whether it is decorative', () => {
+ render()
+
+ expect(screen.queryByRole('img')).not.toBeInTheDocument()
+ expect(screen.getByAltText('')).toHaveAttribute('src', 'avatar.png')
+ expect(screen.getByTestId('avatar')).toHaveAttribute('aria-hidden', 'true')
})
- // Helpers ================================================================
- function getAvatar(
- props?: Omit, 'user'> & {
- user?: { name?: string; email: string }
- },
- ) {
- return (
+ it('supports decorative image avatars with empty alt text', () => {
+ render()
+
+ expect(screen.queryByRole('img')).not.toBeInTheDocument()
+ expect(screen.getByAltText('')).toHaveAttribute('src', 'avatar.png')
+ })
+
+ it('supports decorative initials avatars with empty alt text', () => {
+ render()
+
+ expect(screen.queryByRole('img')).not.toBeInTheDocument()
+ expect(screen.getByTestId('avatar')).toHaveAttribute('aria-hidden', 'true')
+ expect(screen.getByTestId('avatar')).toHaveTextContent('JD')
+ })
+
+ it('applies the escape hatch class name', () => {
+ render(
+ size={36}
+ name="Jane Doe"
+ exceptionallySetClassName="custom-avatar"
+ />,
)
- }
+
+ expect(screen.getByTestId('avatar')).toHaveClass('custom-avatar')
+ })
+
+ describe('a11y', () => {
+ it('renders with no a11y violations', async () => {
+ const { container } = render(
+ <>
+
+
+
+
+
+ >,
+ )
+ const results = await axe(container)
+
+ expect(results).toHaveNoViolations()
+ })
+ })
})
diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx
index 928051384..9704dbc76 100644
--- a/src/avatar/avatar.tsx
+++ b/src/avatar/avatar.tsx
@@ -1,81 +1,218 @@
import * as React from 'react'
+import classNames from 'classnames'
+
import { Box } from '../box'
-import { getClassNames } from '../utils/responsive-props'
+import { polymorphicComponent } from '../utils/polymorphism'
-import { emailToIndex, getInitials } from './utils'
+import {
+ getAvailableImageSources,
+ getAvatarImageIdentityKey,
+ getAvatarMetaColorIndex,
+ getInitials,
+ getSources,
+ normalizeAvatarName,
+ ROUNDED_AVATAR_RADIUS_BY_SIZE,
+} from './utils'
import styles from './avatar.module.css'
import type { ObfuscatedClassName } from '../utils/common-types'
-import type { ResponsiveProp } from '../utils/responsive-props'
-
-const AVATAR_COLORS = [
- '#fcc652',
- '#e9952c',
- '#e16b2d',
- '#d84b40',
- '#e8435a',
- '#e5198a',
- '#ad3889',
- '#86389c',
- '#a8a8a8',
- '#98be2f',
- '#5d9d50',
- '#5f9f85',
- '#5bbcb6',
- '#32a3bf',
- '#2bafeb',
- '#2d88c3',
- '#3863cc',
- '#5e5e5e',
-]
-
-type AvatarSize = 'xxs' | 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl' | 'xxxl'
-
-type Props = ObfuscatedClassName & {
- /** @deprecated Please use `exceptionallySetClassName` */
- className?: string
- /** @deprecated */
- colorList?: string[]
- size?: ResponsiveProp
- avatarUrl?: string
- user: { name?: string; email: string }
+import type { PolymorphicComponentProps } from '../utils/polymorphism'
+import type { AvatarImage, AvatarShape, AvatarSize, ImageSources } from './utils'
+
+type AvatarStyle = React.CSSProperties & {
+ '--reactist-avatar-size': string
+ '--reactist-avatar-rounded-radius': string
}
-function Avatar({
- user,
- avatarUrl,
- size = 'l',
- className,
- colorList = AVATAR_COLORS,
- exceptionallySetClassName,
- ...props
-}: Props) {
- const userInitials = getInitials(user.name) || getInitials(user.email)
- const avatarSize = size ? size : 'l'
-
- const style = avatarUrl
- ? {
- backgroundImage: `url(${avatarUrl})`,
- textIndent: '-9999px', // hide the initials
- }
- : {
- backgroundColor: colorList[emailToIndex(user.email, colorList.length)],
- }
-
- const sizeClassName = getClassNames(styles, 'size', avatarSize)
+/**
+ * Props for the `Avatar` component.
+ */
+type AvatarOwnProps = ObfuscatedClassName & {
+ /**
+ * The rendered avatar size, in CSS pixels.
+ */
+ size: AvatarSize
+
+ /**
+ * The avatar shape.
+ *
+ * @default 'circle'
+ */
+ shape?: AvatarShape
+
+ /**
+ * The display name represented by the avatar.
+ *
+ * Used as the default accessible label, to generate fallback initials, and
+ * to assign the deterministic background color when rendering initials.
+ */
+ name?: string
+
+ /**
+ * The avatar image.
+ *
+ * Pass a string for a single image URL, or a source map keyed by intrinsic
+ * image width. Source maps render as native `srcSet`/`sizes` hints, with
+ * the largest valid source used as the fallback `src`.
+ */
+ image?: AvatarImage
+
+ /**
+ * Accessible text for the avatar image.
+ *
+ * Defaults to `name`. Pass an empty string when the avatar is decorative.
+ */
+ alt?: string
+
+ /**
+ * Test identifier applied to the avatar root element.
+ */
+ 'data-testid'?: string
+
+ /**
+ * Avatar owns its root sizing styles. Use `exceptionallySetClassName` for the styling escape
+ * hatch.
+ */
+ style?: never
+}
+
+type AvatarProps = PolymorphicComponentProps<
+ ComponentType,
+ AvatarOwnProps,
+ 'omitClassName'
+>
+
+const AvatarContent = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>(
+ function AvatarContent(
+ {
+ as,
+ size,
+ shape = 'circle',
+ name,
+ image,
+ alt,
+ exceptionallySetClassName,
+ 'data-testid': testId,
+ 'aria-hidden': ariaHidden,
+ 'aria-label': ariaLabel,
+ ...restProps
+ },
+ ref,
+ ) {
+ const imageSources = getSources(image, size)
+ const [failedImageSources, setFailedImageSources] = React.useState([])
+ const availableImageSources = getAvailableImageSources(imageSources, failedImageSources)
+ const normalizedName = normalizeAvatarName(name)
+ const initials = availableImageSources ? '' : getInitials(name)
+ const hasInitials = initials !== ''
+ const label = ariaLabel ?? alt ?? normalizedName
+ const isDecorative = ariaHidden || label === ''
+ const metaColorIndex = hasInitials ? getAvatarMetaColorIndex(name) : undefined
+
+ return (
+
+ {availableImageSources ? (
+
{
+ const failedSource = getFailedImageSource(
+ availableImageSources,
+ event.currentTarget,
+ )
+
+ setFailedImageSources((currentFailedSources) =>
+ currentFailedSources.includes(failedSource)
+ ? currentFailedSources
+ : [...currentFailedSources, failedSource],
+ )
+ }}
+ />
+ ) : hasInitials ? (
+
+ {initials}
+
+ ) : null}
+
+ )
+ },
+)
+
+/**
+ * Displays an avatar from an image URL, a source map keyed by intrinsic
+ * image width, or initials derived from the provided name (with a background
+ * color).
+ */
+const Avatar = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>(function Avatar(
+ { as, image, ...restProps },
+ ref,
+) {
return (
-
- {userInitials}
-
+
+ )
+})
+
+function getAvatarStyle(size: AvatarSize): AvatarStyle {
+ return {
+ '--reactist-avatar-size': `${size}px`,
+ '--reactist-avatar-rounded-radius': ROUNDED_AVATAR_RADIUS_BY_SIZE[size],
+ }
+}
+
+function getAbsoluteImageSource(src: string, image: HTMLImageElement) {
+ try {
+ return new URL(src, image.ownerDocument.baseURI).href
+ } catch {
+ return src
+ }
+}
+
+function getFailedImageSource(imageProps: ImageSources, image: HTMLImageElement) {
+ const failedSrc = image.currentSrc || image.src || imageProps.src
+ const matchingSource = imageProps.sources?.find(
+ ({ src }) => src === failedSrc || getAbsoluteImageSource(src, image) === failedSrc,
)
+
+ return matchingSource?.src ?? imageProps.src
}
-Avatar.displayName = 'Avatar'
export { Avatar }
+export type { AvatarImage, AvatarProps, AvatarShape, AvatarSize }
diff --git a/src/avatar/index.ts b/src/avatar/index.ts
index 886c6ec3a..c57e70d17 100644
--- a/src/avatar/index.ts
+++ b/src/avatar/index.ts
@@ -1 +1,2 @@
export * from './avatar'
+export * from './avatar-group'
diff --git a/src/avatar/utils.test.ts b/src/avatar/utils.test.ts
index 4cb13c2f0..779ac8561 100644
--- a/src/avatar/utils.test.ts
+++ b/src/avatar/utils.test.ts
@@ -1,53 +1,239 @@
-import { emailToIndex, getInitials } from './utils'
+import {
+ AVATAR_META_COLOR_COUNT,
+ getAvailableImageSources,
+ getAvatarImageIdentityKey,
+ getAvatarMetaColorIndex,
+ getInitials,
+ getSources,
+ normalizeAvatarName,
+ ROUNDED_AVATAR_RADIUS_BY_SIZE,
+} from './utils'
-describe('Utils', () => {
+describe('Avatar utils', () => {
describe('getInitials', () => {
it('returns uppercased initials for two names', () => {
- const initials = getInitials('henning mus')
- expect(initials).toBe('HM')
+ expect(getInitials('jane doe')).toBe('JD')
})
- it('returns first and last name initials for more than two names', () => {
- const initials = getInitials('henning is awesome mus')
- expect(initials).toBe('HM')
+ it('returns first and last initials for more than two names', () => {
+ expect(getInitials('jane middle doe')).toBe('JD')
})
- it('returns first initial for a single name', () => {
- const initials = getInitials('henningmus')
- expect(initials).toBe('H')
+ it('returns the first two grapheme clusters for a single name part', () => {
+ expect(getInitials('jane')).toBe('JA')
})
- it('returns only first initial if first and second initials are the same', () => {
- const initials = getInitials('henning hen')
- expect(initials).toBe('H')
+ it('preserves non-BMP Unicode letter initials', () => {
+ expect(getInitials('\u{10400}eseret doe')).toBe('\u{10400}D')
+ })
+
+ it('preserves decomposed accented initials', () => {
+ expect(getInitials('e\u0301lodie brule\u0301')).toBe('ÉB')
+ })
+
+ it('preserves grapheme clusters that contain combining marks', () => {
+ expect(getInitials('q\u0307bert q\u0307uill')).toBe('Q\u0307Q\u0307')
+ })
+
+ it('limits uppercase-expanding initials to one character per word', () => {
+ expect(getInitials('ßmith Müller')).toBe('SM')
+ })
+
+ it('uppercases the whole name part before taking grapheme clusters', () => {
+ expect(getInitials('ßeta')).toBe('SS')
+ })
+
+ it('keeps matching first and last initials for multiple name parts', () => {
+ expect(getInitials('jane johnson')).toBe('JJ')
+ })
+
+ it('splits name parts by Unicode whitespace', () => {
+ expect(getInitials('Jane\u2003Doe')).toBe('JD')
+ })
+
+ it('does not filter non-letter grapheme clusters from selected name parts', () => {
+ expect(getInitials('🍕 Francesca 🍕 Ciao 🍕')).toBe('🍕🍕')
})
it('returns an empty string for an empty name', () => {
- const initials = getInitials('')
- expect(initials).toBe('')
+ expect(getInitials('')).toBe('')
})
- it('returns an empty string for when called without name', () => {
- const initials = getInitials()
- expect(initials).toBe('')
+ it('returns an empty string when called without a name', () => {
+ expect(getInitials()).toBe('')
})
})
- describe('emailToIndex', () => {
- it('returns an index for a given mail', () => {
- const index = emailToIndex('henning@doist.com', 13)
- expect(index).toBe(12)
+ describe('normalizeAvatarName', () => {
+ it('trims and collapses whitespace', () => {
+ expect(normalizeAvatarName(' Jane Doe ')).toBe('Jane Doe')
})
- it('returns the index if the local part of email is the same', () => {
- const index1 = emailToIndex('henning@doist.com', 13)
- const index2 = emailToIndex('henning@foobar.com', 13)
- expect(index1).toBe(index2)
+ it('returns an empty string for undefined', () => {
+ expect(normalizeAvatarName()).toBe('')
})
- it('returns 0 index if local part of email is empty', () => {
- const index1 = emailToIndex('@doist.com', 13)
- expect(index1).toBe(0)
+ it('returns an empty string for an empty string', () => {
+ expect(normalizeAvatarName('')).toBe('')
+ })
+ })
+
+ describe('getAvatarImageProps', () => {
+ const imageMap = {
+ 36: 'avatar-36.png',
+ 72: 'avatar-72.png',
+ 144: 'avatar-144.png',
+ }
+
+ it('returns a string image directly', () => {
+ expect(getSources('avatar.png', 36)).toEqual({ src: 'avatar.png' })
+ })
+
+ it('uses the largest valid source as the fallback src for source maps', () => {
+ expect(getSources(imageMap, 36)).toEqual({
+ src: 'avatar-144.png',
+ srcSet: 'avatar-36.png 36w, avatar-72.png 72w, avatar-144.png 144w',
+ sizes: '36px',
+ sources: [
+ { sourceSize: 36, src: 'avatar-36.png' },
+ { sourceSize: 72, src: 'avatar-72.png' },
+ { sourceSize: 144, src: 'avatar-144.png' },
+ ],
+ })
+ })
+
+ it('returns undefined for an empty source map', () => {
+ expect(getSources({}, 36)).toBeUndefined()
+ })
+
+ it('ignores invalid source entries', () => {
+ expect(
+ getSources(
+ {
+ '-10': 'avatar-negative.png',
+ 0: 'avatar-zero.png',
+ 36: '',
+ 72: 'avatar-72.png',
+ } as Record,
+ 36,
+ ),
+ ).toEqual({
+ src: 'avatar-72.png',
+ srcSet: 'avatar-72.png 72w',
+ sizes: '36px',
+ sources: [{ sourceSize: 72, src: 'avatar-72.png' }],
+ })
+ })
+ })
+
+ describe('getAvatarImageIdentityKey', () => {
+ it('returns the string image as its identity', () => {
+ expect(getAvatarImageIdentityKey('avatar.png')).toBe('avatar.png')
+ })
+
+ it('returns a stable identity for source maps independent of entry order', () => {
+ expect(
+ getAvatarImageIdentityKey({
+ 144: 'avatar-144.png',
+ 36: 'avatar-36.png',
+ 72: 'avatar-72.png',
+ }),
+ ).toBe('36:avatar-36.png|72:avatar-72.png|144:avatar-144.png')
+ })
+
+ it('uses fallback identity when no valid image source exists', () => {
+ expect(getAvatarImageIdentityKey()).toBe('fallback')
+ expect(getAvatarImageIdentityKey({})).toBe('fallback')
+ expect(
+ getAvatarImageIdentityKey({
+ 0: 'avatar-zero.png',
+ 36: '',
+ }),
+ ).toBe('fallback')
+ })
+ })
+
+ describe('getAvailableAvatarImageProps', () => {
+ it('removes failed source-map candidates and recomputes the fallback src', () => {
+ const imageProps = getSources(
+ {
+ 36: 'avatar-36.png',
+ 72: 'avatar-72.png',
+ 144: 'avatar-144.png',
+ },
+ 36,
+ )
+
+ expect(getAvailableImageSources(imageProps, ['avatar-144.png'])).toEqual({
+ src: 'avatar-72.png',
+ srcSet: 'avatar-36.png 36w, avatar-72.png 72w',
+ sizes: '36px',
+ sources: [
+ { sourceSize: 36, src: 'avatar-36.png' },
+ { sourceSize: 72, src: 'avatar-72.png' },
+ ],
+ })
+ })
+
+ it('returns undefined when a string image has failed', () => {
+ expect(getAvailableImageSources({ src: 'avatar.png' }, ['avatar.png'])).toBeUndefined()
+ })
+
+ it('returns the original image sources when no candidates have failed', () => {
+ const imageProps = getSources(
+ {
+ 36: 'avatar-36.png',
+ 72: 'avatar-72.png',
+ },
+ 36,
+ )
+
+ expect(getAvailableImageSources(imageProps, [])).toBe(imageProps)
+ })
+ })
+
+ describe('getAvatarMetaColorIndex', () => {
+ it('uses 20 fixed meta color slots', () => {
+ expect(AVATAR_META_COLOR_COUNT).toBe(20)
+ })
+
+ it('returns a deterministic index based on the normalized full name', () => {
+ expect(getAvatarMetaColorIndex('Jane Doe')).toBe(0)
+ expect(getAvatarMetaColorIndex('Jane Doe')).toBe(0)
+ expect(getAvatarMetaColorIndex('John Doe')).toBe(9)
+ })
+
+ it('uses the same index for canonically equivalent Unicode names', () => {
+ expect(getAvatarMetaColorIndex('Élodie Brulé')).toBe(
+ getAvatarMetaColorIndex('E\u0301lodie Brule\u0301'),
+ )
+ })
+
+ it('always returns an index in the configured fixed slot range', () => {
+ const index = getAvatarMetaColorIndex('Francesca Ciao')
+
+ expect(index).toBeGreaterThanOrEqual(0)
+ expect(index).toBeLessThan(AVATAR_META_COLOR_COUNT)
+ })
+ })
+
+ describe('ROUNDED_AVATAR_RADIUS_BY_SIZE', () => {
+ it('contains the exclusive rounded radius mapping', () => {
+ expect(ROUNDED_AVATAR_RADIUS_BY_SIZE).toEqual({
+ 80: '10px',
+ 72: '10px',
+ 62: '8.5px',
+ 50: '7px',
+ 40: '5.5px',
+ 36: '5px',
+ 30: '5px',
+ 28: '5px',
+ 24: '3.2px',
+ 20: '3px',
+ 18: '3px',
+ 16: '2px',
+ 12: '1.6px',
+ })
})
})
})
diff --git a/src/avatar/utils.ts b/src/avatar/utils.ts
index 633f9efe1..f8d094093 100644
--- a/src/avatar/utils.ts
+++ b/src/avatar/utils.ts
@@ -1,30 +1,184 @@
+const AVATAR_SIZES = [80, 72, 62, 50, 40, 36, 30, 28, 24, 20, 18, 16, 12] as const
+
+/**
+ * Supported avatar sizes, in CSS pixels.
+ */
+type AvatarSize = (typeof AVATAR_SIZES)[number]
+
+/**
+ * Supported avatar clipping shapes.
+ */
+type AvatarShape = 'circle' | 'rounded'
+
+/**
+ * Avatar image source.
+ *
+ * Use a string for a single image URL, or a source map keyed by intrinsic image width. Source maps
+ * are converted to native `srcSet` width descriptors.
+ */
+type AvatarImage = string | Record
+
+type AvatarImageSource = {
+ sourceSize: number
+ src: string
+}
+
+type ImageSources = {
+ src: string
+ srcSet?: string
+ sizes?: string
+ sources?: AvatarImageSource[]
+}
+
+const AVATAR_META_COLOR_COUNT = 20
+
+const ROUNDED_AVATAR_RADIUS_BY_SIZE: Record = {
+ 80: '10px',
+ 72: '10px',
+ 62: '8.5px',
+ 50: '7px',
+ 40: '5.5px',
+ 36: '5px',
+ 30: '5px',
+ 28: '5px',
+ 24: '3.2px',
+ 20: '3px',
+ 18: '3px',
+ 16: '2px',
+ 12: '1.6px',
+}
+
+const WHITESPACE_REGEXP = new RegExp('\\p{White_Space}+', 'gu')
+const GRAPHEME_SEGMENTER =
+ typeof Intl !== 'undefined' && 'Segmenter' in Intl
+ ? new Intl.Segmenter('und', { granularity: 'grapheme' })
+ : undefined
+
+function normalizeAvatarName(name?: string) {
+ return name?.normalize('NFC').trim().replace(WHITESPACE_REGEXP, ' ') ?? ''
+}
+
+function getGraphemeClusters(value: string) {
+ if (GRAPHEME_SEGMENTER) {
+ return Array.from(GRAPHEME_SEGMENTER.segment(value), ({ segment }) => segment)
+ }
+
+ return Array.from(value)
+}
+
+function getInitialGrapheme(value?: string) {
+ return getGraphemeClusters(value?.toUpperCase() ?? '')[0] ?? ''
+}
+
function getInitials(name?: string) {
- if (!name) {
+ const nameParts = normalizeAvatarName(name).split(' ').filter(Boolean)
+
+ if (nameParts.length === 0) {
return ''
}
- const seed = name.trim().split(' ')
- const firstInitial = seed[0]
- const lastInitial = seed[seed.length - 1]
+ if (nameParts.length === 1) {
+ return getGraphemeClusters(nameParts[0]!.toUpperCase()).slice(0, 2).join('')
+ }
+
+ return `${getInitialGrapheme(nameParts[0])}${getInitialGrapheme(nameParts[nameParts.length - 1])}`
+}
+
+function getSortedImageSources(image: Record): AvatarImageSource[] {
+ return Object.entries(image)
+ .map(([sourceSize, src]) => ({ sourceSize: Number(sourceSize), src }))
+ .filter(({ sourceSize, src }) => Number.isFinite(sourceSize) && sourceSize > 0 && src)
+ .sort((a, b) => a.sourceSize - b.sourceSize)
+}
+
+function getImagePropsFromSources(
+ sources: AvatarImageSource[],
+ sizes?: string,
+): ImageSources | undefined {
+ if (sources.length === 0) {
+ return undefined
+ }
+
+ return {
+ src: sources[sources.length - 1]!.src,
+ srcSet: sources.map(({ sourceSize, src }) => `${src} ${sourceSize}w`).join(', '),
+ sizes,
+ sources,
+ }
+}
- let initials = firstInitial?.[0]
- if (
- firstInitial != null &&
- lastInitial != null &&
- initials != null &&
- // Better readable this way.
- // eslint-disable-next-line @typescript-eslint/prefer-string-starts-ends-with
- firstInitial[0] !== lastInitial[0]
- ) {
- initials += lastInitial[0]
+function getSources(image: AvatarImage | undefined, size: AvatarSize): ImageSources | undefined {
+ if (!image) {
+ return undefined
}
- return initials?.toUpperCase()
+
+ if (typeof image === 'string') {
+ return { src: image }
+ }
+
+ const sources = getSortedImageSources(image)
+ return getImagePropsFromSources(sources, `${size}px`)
}
-function emailToIndex(email: string, maxIndex: number) {
- const seed = email.split('@')[0]
- const hash = seed ? seed.charCodeAt(0) + seed.charCodeAt(seed.length - 1) || 0 : 0
- return hash % maxIndex
+function getAvatarImageIdentityKey(image?: AvatarImage) {
+ if (!image) {
+ return 'fallback'
+ }
+
+ if (typeof image === 'string') {
+ return image
+ }
+
+ const sources = getSortedImageSources(image)
+ if (sources.length === 0) {
+ return 'fallback'
+ }
+
+ return sources.map(({ sourceSize, src }) => `${sourceSize}:${src}`).join('|')
}
-export { emailToIndex, getInitials }
+function getAvailableImageSources(
+ imageProps: ImageSources | undefined,
+ failedSources: readonly string[],
+) {
+ if (!imageProps) {
+ return undefined
+ }
+
+ if (failedSources.length === 0) {
+ return imageProps
+ }
+
+ if (!imageProps.sources) {
+ return failedSources.includes(imageProps.src) ? undefined : imageProps
+ }
+
+ return getImagePropsFromSources(
+ imageProps.sources.filter(({ src }) => !failedSources.includes(src)),
+ imageProps.sizes,
+ )
+}
+
+function getAvatarMetaColorIndex(name?: string) {
+ const normalizedName = normalizeAvatarName(name)
+ let hash = 0
+
+ for (const char of normalizedName) {
+ hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0
+ }
+
+ return hash % AVATAR_META_COLOR_COUNT
+}
+
+export {
+ AVATAR_META_COLOR_COUNT,
+ AVATAR_SIZES,
+ getAvailableImageSources,
+ getAvatarImageIdentityKey,
+ getAvatarMetaColorIndex,
+ getInitials,
+ getSources,
+ normalizeAvatarName,
+ ROUNDED_AVATAR_RADIUS_BY_SIZE,
+}
+export type { AvatarImage, AvatarImageSource, AvatarShape, AvatarSize, ImageSources }
diff --git a/src/utils/polymorphism.ts b/src/utils/polymorphism.ts
index afb26bbdf..b7e56297f 100644
--- a/src/utils/polymorphism.ts
+++ b/src/utils/polymorphism.ts
@@ -197,5 +197,5 @@ function polymorphicComponent<
>
}
-export type { PolymorphicComponent }
+export type { PolymorphicComponent, PolymorphicComponentProps }
export { polymorphicComponent }
diff --git a/stories/components/Avatar.stories.tsx b/stories/components/Avatar.stories.tsx
deleted file mode 100644
index 51eea3f7c..000000000
--- a/stories/components/Avatar.stories.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-import './styles/avatar_story.css'
-
-import * as React from 'react'
-
-import { Avatar, Box, Inline } from '../../src'
-
-export default {
- title: 'Components/Avatar',
- component: Avatar,
-}
-
-const exampleData = [
- {
- size: 'xxs',
- user: { name: 'Henning Mu', email: 'henning@foo.com' },
- image: 'https://loremflickr.com/320/320',
- },
- {
- size: 'xs',
- user: { name: 'João Va', email: 'joao@foo.com' },
- image: 'https://loremflickr.com/320/320',
- },
- {
- size: 's',
- user: { name: 'Amir Sa', email: 'amir@foo.com' },
- image: 'https://loremflickr.com/320/320',
- },
- {
- size: 'm',
- user: { name: 'Alex Mu', email: 'alex@foo.com' },
- image: 'https://loremflickr.com/320/320',
- },
- {
- size: 'l',
- user: { name: 'Julia', email: 'julia@foo.com' },
- image: 'https://loremflickr.com/320/320',
- },
- {
- size: 'xl',
- user: { name: 'Janusz Gr', email: 'janusz@foo.com' },
- image: 'https://loremflickr.com/320/320',
- },
- {
- size: 'xxl',
- user: { name: 'Jaime Az', email: 'jaime@foo.com' },
- image: 'https://loremflickr.com/320/320',
- },
- {
- size: 'xxxl',
- user: { name: 'Igor Kh', email: 'igor@foo.com' },
- image: 'https://loremflickr.com/320/320',
- },
-] as const
-
-// Story Definitions ==========================================================
-
-export const InitialsAvatarStory = () => (
-
- {exampleData.map((data, index) => (
-
- ))}
-
-)
-
-export const CustomColorAvatarStory = () => (
-
- {exampleData.map((data, index) => (
-
- ))}
-
-)
-
-export const PictureAvatarStory = () => (
-
- {exampleData.map((data, index) => (
-
- ))}
-
-)
-
-export const AvatarPlaygroundStory = (args) => {
- return (
-
-
-
- )
-}
-
-AvatarPlaygroundStory.args = {
- size: 'l',
- avatarUrl: 'https://loremflickr.com/320/320',
- userName: '',
- email: '',
-}
-
-AvatarPlaygroundStory.argTypes = {
- size: {
- type: 'select',
- options: ['xxs', 'xs', 's', 'm', 'l', 'xl', 'xxl', 'xxxl'],
- },
- avatarUrl: {
- control: {
- type: 'text',
- },
- },
- userName: {
- control: {
- type: 'text',
- },
- },
- email: {
- control: {
- type: 'text',
- },
- },
- className: {
- control: {
- type: null,
- },
- },
- user: {
- control: {
- type: null,
- },
- },
- colorList: {
- control: {
- type: null,
- },
- },
-}
diff --git a/tsconfig.json b/tsconfig.json
index e035be3b1..e60c62b6f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -14,6 +14,8 @@
"baseUrl": "./",
"paths": {
"@": ["./"],
+ "@storybook/react": ["node_modules/@storybook/react/dist/index.d.ts"],
+ "@storybook/react-vite": ["node_modules/@storybook/react-vite/dist/index.d.ts"],
"storybook/actions": ["node_modules/storybook/dist/actions/index.d.ts"],
"storybook/test": ["node_modules/storybook/dist/test/index.d.ts"],
"*": ["src/*", "node_modules/*"]