diff --git a/.changeset/small-avatars-smile.md b/.changeset/small-avatars-smile.md
new file mode 100644
index 00000000000..24e0458bdec
--- /dev/null
+++ b/.changeset/small-avatars-smile.md
@@ -0,0 +1,5 @@
+---
+'@primer/react': minor
+---
+
+Add text placeholder support to Avatar when no image source is provided.
diff --git a/e2e/components/Avatar.test.ts b/e2e/components/Avatar.test.ts
index 00d706cc0e1..b398b91601b 100644
--- a/e2e/components/Avatar.test.ts
+++ b/e2e/components/Avatar.test.ts
@@ -19,6 +19,10 @@ const stories = [
title: 'Square',
id: 'components-avatar-features--square',
},
+ {
+ title: 'Text Placeholder',
+ id: 'components-avatar-features--text-placeholder',
+ },
] as const
test.describe('Avatar', () => {
diff --git a/packages/react/src/Avatar/Avatar.docs.json b/packages/react/src/Avatar/Avatar.docs.json
index bfe70bf9206..8de0b951ae1 100644
--- a/packages/react/src/Avatar/Avatar.docs.json
+++ b/packages/react/src/Avatar/Avatar.docs.json
@@ -15,6 +15,9 @@
},
{
"id": "components-avatar-features--size-responsive"
+ },
+ {
+ "id": "components-avatar-features--text-placeholder"
}
],
"importPath": "@primer/react",
@@ -41,8 +44,14 @@
"name": "src",
"type": "string",
"required": false,
- "description": "URL of the avatar image.",
+ "description": "URL of the avatar image. When provided, the image takes precedence over the placeholder.",
"defaultValue": ""
+ },
+ {
+ "name": "placeholder",
+ "type": "string",
+ "required": false,
+ "description": "Text to display when no image URL is provided, such as a user's initials."
}
],
"subcomponents": []
diff --git a/packages/react/src/Avatar/Avatar.features.stories.tsx b/packages/react/src/Avatar/Avatar.features.stories.tsx
index 6627d744d7b..fafbf2ba653 100644
--- a/packages/react/src/Avatar/Avatar.features.stories.tsx
+++ b/packages/react/src/Avatar/Avatar.features.stories.tsx
@@ -79,3 +79,12 @@ export const SizeResponsive = () => (
/>
)
+
+export const TextPlaceholder = () => (
+
+)
diff --git a/packages/react/src/Avatar/Avatar.module.css b/packages/react/src/Avatar/Avatar.module.css
index fc67531b9a7..2f157b70a5f 100644
--- a/packages/react/src/Avatar/Avatar.module.css
+++ b/packages/react/src/Avatar/Avatar.module.css
@@ -10,6 +10,17 @@
/* stylelint-disable-next-line primer/box-shadow */
box-shadow: 0 0 0 1px var(--avatar-borderColor);
+ &:where([data-placeholder]) {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bgColor-muted);
+ color: var(--fgColor-muted);
+ font-size: var(--text-body-size-small);
+ font-weight: var(--base-text-weight-semibold);
+ white-space: nowrap;
+ }
+
&:where([data-square]) {
/* stylelint-disable-next-line primer/borders */
border-radius: clamp(4px, calc(var(--avatarSize-regular) - 24px), var(--borderRadius-medium));
diff --git a/packages/react/src/Avatar/Avatar.test.tsx b/packages/react/src/Avatar/Avatar.test.tsx
index c7d7ff4f0f9..e67354c82af 100644
--- a/packages/react/src/Avatar/Avatar.test.tsx
+++ b/packages/react/src/Avatar/Avatar.test.tsx
@@ -37,6 +37,31 @@ describe('Avatar', () => {
await expect.element(avatar).toHaveAttribute('src', 'primer.png')
})
+ it('renders a text placeholder when src is omitted', async () => {
+ render()
+ const avatar = page.getByTestId('avatar')
+ await expect.element(avatar).toHaveTextContent('NS')
+ await expect.element(avatar).toHaveAttribute('role', 'img')
+ await expect.element(avatar).toHaveAttribute('aria-label', 'NS')
+ await expect.element(avatar).toHaveAttribute('data-placeholder')
+ await expect.element(avatar).toHaveAttribute('data-component', 'Avatar')
+ await expect.element(avatar).not.toHaveAttribute('src')
+ })
+
+ it('prefers the image when both src and placeholder are provided', async () => {
+ render()
+ const avatar = page.getByTestId('avatar')
+ expect(avatar.element().tagName).toBe('IMG')
+ await expect.element(avatar).toHaveAttribute('src', 'primer.png')
+ await expect.element(avatar).toHaveAttribute('alt', 'Naveed')
+ await expect.element(avatar).not.toHaveAttribute('data-placeholder')
+ })
+
+ it('uses alt text as the accessible name for a placeholder when provided', async () => {
+ render()
+ await expect.element(page.getByTestId('avatar')).toHaveAttribute('aria-label', 'Naveed Sajjad')
+ })
+
it('should support the `style` prop without overriding internal styles', async () => {
render(
/** Sets the shape of the avatar to a square if true. If false, the avatar will be circular. */
square?: boolean
- /** URL of the avatar image. */
- src: string
+ /** URL of the avatar image. When provided, the image takes precedence over the placeholder. */
+ src?: string
+ /** Text to display when no image URL is provided, such as a user's initials. */
+ placeholder?: string
/** Provide alt text when the Avatar is used without the user's name next to it. */
alt?: string
/** Additional class name. */
className?: string
-} & React.ComponentPropsWithoutRef<'img'>
+} & Omit, 'src'>
-const Avatar = React.forwardRef(function Avatar(
- {alt = '', size = DEFAULT_AVATAR_SIZE, square = false, className, style, ...rest},
+const Avatar = React.forwardRef(function Avatar(
+ {alt = '', size = DEFAULT_AVATAR_SIZE, square = false, placeholder, src, className, style, ...rest},
ref,
) {
const isResponsive = isResponsiveValue(size)
@@ -34,26 +36,43 @@ const Avatar = React.forwardRef(function Avatar(
cssSizeVars['--avatarSize-regular'] = `${size}px`
}
+ const sharedProps = {
+ 'data-component': 'Avatar',
+ className: clsx(className, classes.Avatar),
+ 'data-responsive': isResponsive ? '' : undefined,
+ 'data-square': square ? '' : undefined,
+ style: style
+ ? {
+ ...cssSizeVars,
+ ...style,
+ }
+ : (cssSizeVars as React.CSSProperties),
+ ...rest,
+ }
+
+ if (src) {
+ return (
+
}
+ alt={alt}
+ width={isResponsive ? undefined : size}
+ height={isResponsive ? undefined : size}
+ src={src}
+ />
+ )
+ }
+
return (
-
+ }
+ role="img"
+ aria-label={alt || placeholder || undefined}
+ data-placeholder=""
+ >
+ {placeholder}
+
)
})