diff --git a/.changeset/cruel-items-dig.md b/.changeset/cruel-items-dig.md
new file mode 100644
index 0000000000..65f07956ac
--- /dev/null
+++ b/.changeset/cruel-items-dig.md
@@ -0,0 +1,27 @@
+---
+'@backstage/ui': minor
+---
+
+**BREAKING**: Migrated Avatar component from Base UI to custom implementation with size changes:
+
+- Base UI-specific props are no longer supported
+- Size values have been updated:
+ - New `x-small` size added (1.25rem / 20px)
+ - `small` size unchanged (1.5rem / 24px)
+ - `medium` size unchanged (2rem / 32px, default)
+ - `large` size **changed from 3rem to 2.5rem** (40px)
+ - New `x-large` size added (3rem / 48px)
+
+Migration:
+
+```diff
+# Remove Base UI-specific props
+-
++
+
+# Update large size usage to x-large for same visual size
+-
++
+```
+
+Added `purpose` prop for accessibility control (`'informative'` or `'decoration'`).
diff --git a/docs-ui/src/content/components/avatar.mdx b/docs-ui/src/content/components/avatar.mdx
index be1fb070d1..6e0b550101 100644
--- a/docs-ui/src/content/components/avatar.mdx
+++ b/docs-ui/src/content/components/avatar.mdx
@@ -7,6 +7,7 @@ import {
snippetUsage,
snippetSizes,
snippetFallback,
+ snippetPurpose,
} from './avatar.props';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
@@ -58,6 +59,17 @@ If the image is not available, the avatar will show the initials of the name.
code={snippetFallback}
/>
+### The `purpose` prop
+
+Control how the avatar is announced to screen readers using the `purpose` prop.
+
+}
+ code={snippetPurpose}
+/>
+
diff --git a/docs-ui/src/content/components/avatar.props.ts b/docs-ui/src/content/components/avatar.props.ts
index 8cc53eeff3..80621cdb89 100644
--- a/docs-ui/src/content/components/avatar.props.ts
+++ b/docs-ui/src/content/components/avatar.props.ts
@@ -10,10 +10,15 @@ export const avatarPropDefs: Record = {
},
size: {
type: 'enum',
- values: ['small', 'medium', 'large'],
+ values: ['x-small', 'small', 'medium', 'large', 'x-large'],
default: 'medium',
responsive: true,
},
+ purpose: {
+ type: 'enum',
+ values: ['informative', 'decoration'],
+ default: 'informative',
+ },
...classNamePropDefs,
...stylePropDefs,
};
@@ -26,6 +31,10 @@ export const snippetUsage = `import { Avatar } from '@backstage/ui';
/>`;
export const snippetSizes = `
+
src="https://avatars.githubusercontent.com/u/1540635?v=4"
name="Charles de Dreuille" size="large"
/>
+
`;
export const snippetFallback = ``;
+
+export const snippetPurpose = `
+
+ Informative (default)
+
+ Use when avatar appears alone. Announced as "Charles de Dreuille" to screen readers:
+
+
+
+
+
+
+ Decoration
+
+ Use when name appears adjacent to avatar. Hidden from screen readers to avoid redundancy:
+
+
+
+ Charles de Dreuille
+
+
+`;
diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md
index 91b6def80a..05d2336f45 100644
--- a/packages/ui/report.api.md
+++ b/packages/ui/report.api.md
@@ -3,7 +3,6 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
-import { Avatar as Avatar_2 } from '@base-ui-components/react/avatar';
import { ButtonProps as ButtonProps_2 } from 'react-aria-components';
import { CellProps as CellProps_2 } from 'react-aria-components';
import { CheckboxProps as CheckboxProps_2 } from 'react-aria-components';
@@ -57,17 +56,14 @@ export type AlignItems = 'stretch' | 'start' | 'center' | 'end';
// @public (undocumented)
export const Avatar: ForwardRefExoticComponent<
- AvatarProps & RefAttributes
+ AvatarProps & RefAttributes
>;
// @public (undocumented)
-export interface AvatarProps
- extends React.ComponentPropsWithoutRef {
- // (undocumented)
+export interface AvatarProps extends React.ComponentPropsWithoutRef<'div'> {
name: string;
- // (undocumented)
- size?: 'small' | 'medium' | 'large';
- // (undocumented)
+ purpose?: 'decoration' | 'informative';
+ size?: 'x-small' | 'small' | 'medium' | 'large' | 'x-large';
src: string;
}
diff --git a/packages/ui/src/components/Avatar/Avatar.module.css b/packages/ui/src/components/Avatar/Avatar.module.css
index f1517db3c2..c1fec3df9b 100644
--- a/packages/ui/src/components/Avatar/Avatar.module.css
+++ b/packages/ui/src/components/Avatar/Avatar.module.css
@@ -34,6 +34,11 @@
width: 2rem;
}
+ .bui-AvatarRoot[data-size='x-small'] {
+ height: 1.25rem;
+ width: 1.25rem;
+ }
+
.bui-AvatarRoot[data-size='small'] {
height: 1.5rem;
width: 1.5rem;
@@ -45,6 +50,11 @@
}
.bui-AvatarRoot[data-size='large'] {
+ height: 2.5rem;
+ width: 2.5rem;
+ }
+
+ .bui-AvatarRoot[data-size='x-large'] {
height: 3rem;
width: 3rem;
}
@@ -53,6 +63,7 @@
object-fit: cover;
height: 100%;
width: 100%;
+ display: block;
}
.bui-AvatarFallback {
diff --git a/packages/ui/src/components/Avatar/Avatar.stories.tsx b/packages/ui/src/components/Avatar/Avatar.stories.tsx
index c91fe736db..2ad51e4a5d 100644
--- a/packages/ui/src/components/Avatar/Avatar.stories.tsx
+++ b/packages/ui/src/components/Avatar/Avatar.stories.tsx
@@ -16,7 +16,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Avatar } from './index';
-import { Flex } from '../..';
+import { Flex, Text } from '../..';
const meta = {
title: 'Backstage UI/Avatar',
@@ -46,9 +46,42 @@ export const Sizes: Story = {
},
render: args => (
+
+
+
+ ),
+};
+
+export const Purpose: Story = {
+ args: {
+ ...Default.args,
+ },
+ render: args => (
+
+
+ Informative (default)
+
+ Use when avatar appears alone. Announced as "{args.name}" to screen
+ readers:
+
+
+
+
+
+
+ Decoration
+
+ Use when name appears adjacent to avatar. Hidden from screen readers
+ to avoid redundancy:
+
+
+
+ {args.name}
+
+
),
};
diff --git a/packages/ui/src/components/Avatar/Avatar.tsx b/packages/ui/src/components/Avatar/Avatar.tsx
index 000087d451..1490d86098 100644
--- a/packages/ui/src/components/Avatar/Avatar.tsx
+++ b/packages/ui/src/components/Avatar/Avatar.tsx
@@ -14,48 +14,72 @@
* limitations under the License.
*/
-import { forwardRef, ElementRef } from 'react';
-import { Avatar as AvatarPrimitive } from '@base-ui-components/react/avatar';
+import { forwardRef, useState, useEffect } from 'react';
import clsx from 'clsx';
import { AvatarProps } from './types';
import { useStyles } from '../../hooks/useStyles';
import styles from './Avatar.module.css';
/** @public */
-export const Avatar = forwardRef<
- ElementRef,
- AvatarProps
->((props, ref) => {
+export const Avatar = forwardRef((props, ref) => {
const { classNames, dataAttributes, cleanedProps } = useStyles('Avatar', {
size: 'medium',
+ purpose: 'informative',
...props,
});
- const { className, src, name, ...rest } = cleanedProps;
+ const { className, src, name, purpose, ...rest } = cleanedProps;
+
+ const [imageStatus, setImageStatus] = useState<
+ 'loading' | 'loaded' | 'error'
+ >('loading');
+
+ useEffect(() => {
+ setImageStatus('loading');
+ const img = new Image();
+ img.onload = () => setImageStatus('loaded');
+ img.onerror = () => setImageStatus('error');
+ img.src = src;
+
+ return () => {
+ img.onload = null;
+ img.onerror = null;
+ };
+ }, [src]);
+
+ const initials = name
+ .split(' ')
+ .map(word => word[0])
+ .join('')
+ .toLocaleUpperCase('en-US')
+ .slice(0, 2);
return (
-
-
-
- {(name || '')
- .split(' ')
- .map(word => word[0])
- .join('')
- .toLocaleUpperCase('en-US')
- .slice(0, 2)}
-
-
+ {imageStatus === 'loaded' ? (
+
+ ) : (
+
+ {initials}
+
+ )}
+
);
});
-Avatar.displayName = AvatarPrimitive.Root.displayName;
+Avatar.displayName = 'Avatar';
diff --git a/packages/ui/src/components/Avatar/types.ts b/packages/ui/src/components/Avatar/types.ts
index b2e6cf39cf..a252861ea4 100644
--- a/packages/ui/src/components/Avatar/types.ts
+++ b/packages/ui/src/components/Avatar/types.ts
@@ -14,12 +14,29 @@
* limitations under the License.
*/
-import { Avatar } from '@base-ui-components/react/avatar';
-
/** @public */
-export interface AvatarProps
- extends React.ComponentPropsWithoutRef {
+export interface AvatarProps extends React.ComponentPropsWithoutRef<'div'> {
+ /**
+ * URL of the image to display
+ */
src: string;
+
+ /**
+ * Name of the person - used for generating initials and accessibility labels
+ */
name: string;
- size?: 'small' | 'medium' | 'large';
+
+ /**
+ * Size of the avatar
+ * @defaultValue 'medium'
+ */
+ size?: 'x-small' | 'small' | 'medium' | 'large' | 'x-large';
+
+ /**
+ * Determines how the avatar is presented to assistive technologies.
+ * - 'informative': Avatar is announced as "\{name\}" to screen readers
+ * - 'decoration': Avatar is hidden from screen readers (use when name appears in adjacent text)
+ * @defaultValue 'informative'
+ */
+ purpose?: 'decoration' | 'informative';
}