feat(ui): migrate Avatar from Base UI with updated size scale

Removed Base UI dependency from Avatar component and reimplemented
with native HTML elements. Updated size scale with new x-small and
x-large options.

Breaking changes:

- Base UI-specific props (render, etc.) are no longer supported
- Component now uses native div/img elements instead of Base UI primitives
- Size scale updated: large changed from 3rem to 2.5rem
- Added x-small (1.25rem) and x-large (3rem) sizes

Migration:

- <Avatar src="..." name="..." render={...} />
+ <Avatar src="..." name="..." />

- <Avatar size="large" />
+ <Avatar size="x-large" />

New features:

- Added purpose prop with 'informative' (default) and 'decoration' options
- Informative avatars announce name to screen readers
- Decorative avatars hidden from screen readers (use when name appears adjacent)
- Five size options: x-small, small, medium, large, x-large

Documentation updates:

- Updated size examples to show all five sizes
- Added Purpose story and documentation
- Updated prop definitions and usage examples
- Updated changeset with migration guide for size changes

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2025-10-24 17:53:00 +02:00
parent 8524186b21
commit 539cf2690a
8 changed files with 202 additions and 39 deletions
+27
View File
@@ -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
- <Avatar src="..." name="..." render={...} />
+ <Avatar src="..." name="..." />
# Update large size usage to x-large for same visual size
- <Avatar src="..." name="..." size="large" />
+ <Avatar src="..." name="..." size="x-large" />
```
Added `purpose` prop for accessibility control (`'informative'` or `'decoration'`).
+12
View File
@@ -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.
<Snippet
align="center"
py={4}
preview={<AvatarSnippet story="Purpose" />}
code={snippetPurpose}
/>
<Theming component="Avatar" />
<ChangelogComponent component="avatar" />
+44 -1
View File
@@ -10,10 +10,15 @@ export const avatarPropDefs: Record<string, PropDef> = {
},
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 = `<Flex gap="4" direction="column">
<Avatar
src="https://avatars.githubusercontent.com/u/1540635?v=4"
name="Charles de Dreuille" size="x-small"
/>
<Avatar
src="https://avatars.githubusercontent.com/u/1540635?v=4"
name="Charles de Dreuille" size="small"
@@ -38,9 +47,43 @@ export const snippetSizes = `<Flex gap="4" direction="column">
src="https://avatars.githubusercontent.com/u/1540635?v=4"
name="Charles de Dreuille" size="large"
/>
<Avatar
src="https://avatars.githubusercontent.com/u/1540635?v=4"
name="Charles de Dreuille" size="x-large"
/>
</Flex>`;
export const snippetFallback = `<Avatar
src="https://avatars.githubusercontent.com/u/15406AAAAAAAAA"
name="Charles de Dreuille"
/>`;
export const snippetPurpose = `<Flex direction="column" gap="4">
<Flex direction="column" gap="1">
<Text variant="title-x-small">Informative (default)</Text>
<Text variant="body-medium">
Use when avatar appears alone. Announced as "Charles de Dreuille" to screen readers:
</Text>
<Flex gap="2" align="center">
<Avatar
src="https://avatars.githubusercontent.com/u/1540635?v=4"
name="Charles de Dreuille"
purpose="informative"
/>
</Flex>
</Flex>
<Flex direction="column" gap="1">
<Text variant="title-x-small">Decoration</Text>
<Text variant="body-medium">
Use when name appears adjacent to avatar. Hidden from screen readers to avoid redundancy:
</Text>
<Flex gap="2" align="center">
<Avatar
src="https://avatars.githubusercontent.com/u/1540635?v=4"
name="Charles de Dreuille"
purpose="decoration"
/>
<Text>Charles de Dreuille</Text>
</Flex>
</Flex>
</Flex>`;
+4 -8
View File
@@ -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<HTMLSpanElement>
AvatarProps & RefAttributes<HTMLDivElement>
>;
// @public (undocumented)
export interface AvatarProps
extends React.ComponentPropsWithoutRef<typeof Avatar_2.Root> {
// (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;
}
@@ -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 {
@@ -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 => (
<Flex>
<Avatar {...args} size="x-small" />
<Avatar {...args} size="small" />
<Avatar {...args} size="medium" />
<Avatar {...args} size="large" />
<Avatar {...args} size="x-large" />
</Flex>
),
};
export const Purpose: Story = {
args: {
...Default.args,
},
render: args => (
<Flex direction="column" gap="4">
<Flex direction="column" gap="1">
<Text variant="title-x-small">Informative (default)</Text>
<Text variant="body-medium">
Use when avatar appears alone. Announced as "{args.name}" to screen
readers:
</Text>
<Flex gap="2" align="center">
<Avatar {...args} purpose="informative" />
</Flex>
</Flex>
<Flex direction="column" gap="1">
<Text variant="title-x-small">Decoration</Text>
<Text variant="body-medium">
Use when name appears adjacent to avatar. Hidden from screen readers
to avoid redundancy:
</Text>
<Flex gap="2" align="center">
<Avatar {...args} purpose="decoration" />
<Text>{args.name}</Text>
</Flex>
</Flex>
</Flex>
),
};
+48 -24
View File
@@ -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<typeof AvatarPrimitive.Root>,
AvatarProps
>((props, ref) => {
export const Avatar = forwardRef<HTMLDivElement, AvatarProps>((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 (
<AvatarPrimitive.Root
<div
ref={ref}
role="img"
aria-label={purpose === 'informative' ? name : undefined}
aria-hidden={purpose === 'decoration' ? true : undefined}
className={clsx(classNames.root, styles[classNames.root], className)}
{...dataAttributes}
{...rest}
>
<AvatarPrimitive.Image
className={clsx(classNames.image, styles[classNames.image])}
src={src}
/>
<AvatarPrimitive.Fallback
className={clsx(classNames.fallback, styles[classNames.fallback])}
>
{(name || '')
.split(' ')
.map(word => word[0])
.join('')
.toLocaleUpperCase('en-US')
.slice(0, 2)}
</AvatarPrimitive.Fallback>
</AvatarPrimitive.Root>
{imageStatus === 'loaded' ? (
<img
src={src}
alt=""
className={clsx(classNames.image, styles[classNames.image])}
/>
) : (
<div
aria-hidden="true"
className={clsx(classNames.fallback, styles[classNames.fallback])}
>
{initials}
</div>
)}
</div>
);
});
Avatar.displayName = AvatarPrimitive.Root.displayName;
Avatar.displayName = 'Avatar';
+22 -5
View File
@@ -14,12 +14,29 @@
* limitations under the License.
*/
import { Avatar } from '@base-ui-components/react/avatar';
/** @public */
export interface AvatarProps
extends React.ComponentPropsWithoutRef<typeof Avatar.Root> {
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';
}