Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5daa8b8
fix: move accessibility props from avatar view to its image
oleksandrzavarzin-callstack Aug 19, 2026
3e9a6e3
fix: make avatar initials grapheme-safe
oleksandrzavarzin-callstack Aug 19, 2026
df2efb2
feat: add an avatar image fallback API
oleksandrzavarzin-callstack Aug 20, 2026
73af3dd
fix: expanded avatar text/icon contrasted color
oleksandrzavarzin-callstack Aug 20, 2026
482e436
fix: filled minor gaps of the avatar fixes
oleksandrzavarzin-callstack Aug 21, 2026
5ed8a6f
refactor: adopt MD3 avatar tokens from #5016
oleksandrzavarzin-callstack Aug 21, 2026
ce5ea36
docs: update the docs to represent avatar changes
oleksandrzavarzin-callstack Aug 21, 2026
64c33a8
refactor: move avatar accessibility props to use alt
oleksandrzavarzin-callstack Aug 21, 2026
0e7c864
fix: add aria-label to AvatarImage props
oleksandrzavarzin-callstack Aug 28, 2026
df10c5d
Merge remote-tracking branch 'upstream/main' into fix/define-avatar-i…
oleksandrzavarzin-callstack Sep 3, 2026
ea72607
fix: remove unsafe type-assertion from avatarUtils tests
oleksandrzavarzin-callstack Sep 4, 2026
d7c34ee
Merge remote-tracking branch 'upstream/main' into fix/define-avatar-i…
oleksandrzavarzin-callstack Sep 14, 2026
4dd612d
fix: apply review suggestions from @satya164
oleksandrzavarzin-callstack Oct 23, 2026
775f2d2
fix: warn when a dynamic background has no theme pair
oleksandrzavarzin-callstack Oct 27, 2026
5edcd51
fix: keep text avatar out of the focus order
oleksandrzavarzin-callstack Oct 27, 2026
16ae54e
fix: hide image fallback behind a described host
oleksandrzavarzin-callstack Oct 27, 2026
2686354
fix: give labelled avatars the img role
oleksandrzavarzin-callstack Oct 27, 2026
5e6c591
feat: add avatar alt, fallback and function source examples
oleksandrzavarzin-callstack Oct 27, 2026
bd7d35c
feat: add sourceKey to retry a failed function source
oleksandrzavarzin-callstack Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion example/src/Examples/AvatarExample.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { StyleSheet, View } from 'react-native';
import { Image, StyleSheet, View } from 'react-native';

import { Avatar, List, Palette } from 'react-native-paper';

Expand Down Expand Up @@ -44,6 +44,7 @@ const AvatarExample = () => {
<Avatar.Image
style={styles.avatar}
source={require('../../assets/images/avatar.png')}
alt="Jane Doe"
/>
<Avatar.Image
style={styles.avatar}
Expand All @@ -52,6 +53,30 @@ const AvatarExample = () => {
/>
</View>
</List.Section>
<List.Section title="Image fallback">
<View style={styles.row}>
<Avatar.Image
style={styles.avatar}
source={{ uri: 'file:///missing-avatar.png' }}
alt="Jane Doe"
fallback={({ size }) => <Avatar.Text size={size} label="JD" />}
/>
<Avatar.Image
style={styles.avatar}
source={({ style, onError, ...a11y }) => (
<Image
source={require('../../assets/images/avatar.png')}
style={style}
onError={onError}
accessibilityIgnoresInvertColors
{...a11y}
/>
)}
alt="Jane Doe"
fallback={({ size }) => <Avatar.Text size={size} label="JD" />}
/>
</View>
</List.Section>
</ScreenWrapper>
);
};
Expand Down
49 changes: 35 additions & 14 deletions src/components/Avatar/AvatarIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { StyleSheet, View } from 'react-native';
import type { StyleProp, ViewProps, ViewStyle } from 'react-native';

import {
DEFAULT_SIZE,
getAvatarRole,
ICON_SIZE_RATIO,
resolveAvatarColors,
} from './utils';
import { useInternalTheme } from '../../core/theming';
import { white } from '../../theme/colors';
import { cornerFull } from '../../theme/tokens/sys/shape';
import type { ThemeProp } from '../../theme/types';
import getContrastingColor from '../../utils/getContrastingColor';
import Icon from '../Icon';
import type { IconSource } from '../Icon';

const defaultSize = 64;

export type Props = ViewProps & {
/**
* Icon to display for the `Avatar`.
Expand All @@ -20,9 +23,19 @@ export type Props = ViewProps & {
*/
size?: number;
/**
* Custom color for the icon.
* Custom color for the icon. Takes precedence over the automatic contrast
* color below.
*/
color?: string;
/**
* Style for the icon container. A custom `backgroundColor` is
* automatically paired with a contrasting icon color when `color` is not
* set: string values use a luminance heuristic, while opaque/dynamic
* values (`PlatformColor` / `DynamicColorIOS`) are paired with a theme
* role's `on-` color, falling back to white. Pass `color` for a
* dynamic background that is not in the theme, because its contrast
* cannot be checked.
*/
style?: StyleProp<ViewStyle>;
/**
* @optional
Expand All @@ -45,33 +58,41 @@ export type Props = ViewProps & {
*/
const Avatar = ({
icon,
size = defaultSize,
size = DEFAULT_SIZE,
style,
theme: themeOverrides,
color: customColor,
...rest
}: Props) => {
const theme = useInternalTheme(themeOverrides);
const { backgroundColor = theme.colors?.primary, ...restStyle } =
StyleSheet.flatten(style) || {};
const textColor =
rest.color ??
getContrastingColor(backgroundColor, white, 'rgba(0, 0, 0, .54)');
const { backgroundColor, ...restStyle } = StyleSheet.flatten(style) || {};
const { background, textColor } = resolveAvatarColors({
theme,
backgroundColor,
color: customColor,
});
const hasLabel =
rest.accessibilityLabel !== undefined || rest['aria-label'] !== undefined;

return (
<View
style={[
{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor,
borderRadius: cornerFull,
backgroundColor: background,
},
styles.container,
restStyle,
]}
{...(hasLabel && {
accessible: true,
...getAvatarRole(rest.accessibilityRole),
})}
{...rest}
>
<Icon source={icon} color={textColor} size={size * 0.6} />
<Icon source={icon} color={textColor} size={size * ICON_SIZE_RATIO} />
</View>
);
};
Expand Down
162 changes: 148 additions & 14 deletions src/components/Avatar/AvatarImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,75 @@ import type {
ViewStyle,
} from 'react-native';

import {
DEFAULT_SIZE,
getAvatarImageSourceKey,
getAvatarRole,
resolveAvatarColors,
} from './utils';
import { useInternalTheme } from '../../core/theming';
import { cornerFull } from '../../theme/tokens/sys/shape';
import type { ThemeProp } from '../../theme/types';

const defaultSize = 64;
export type AvatarImageSourceProps = {
size: number;
style: { width: number; height: number; borderRadius: number };
onError?: ImageProps['onError'];
/**
* Present when the host received a description through `alt`, `aria-label`
* or `accessibilityLabel`. Pass it to your image so it is announced by
* assistive technology.
*/
alt?: string;
/**
* Mirrors `alt`. `react-native-web` fills the rendered `<img alt>` from
* `aria-label` only, so pass both to your image.
*/
'aria-label'?: string;
/**
* `false` when the host received no description, marking the image as
* decorative.
*/
accessible?: boolean;
};

export type AvatarImageSource =
| ImageSourcePropType
| ((props: { size: number }) => React.ReactNode);
| ((props: AvatarImageSourceProps) => React.ReactNode);

export type Props = ViewProps & {
/**
* Image to display for the `Avatar`.
* It accepts a standard React Native Image `source` prop
* Or a function that returns an `Image`.
* or a function that returns an image component.
* Function sources receive `{ size, style, onError, alt, 'aria-label',
* accessible }` matching the host avatar. Apply `style` so the image fills
* the circle, forward the accessibility props for assistive technology, and
* call `onError` to trigger `fallback`.
*/
source: AvatarImageSource;
/**
* Identity of the image that a function `source` renders, for example the
* user id. When it changes, a failed function source loads again instead of
* keeping `fallback`. Without it, `fallback` stays after a function source
* fails. Other sources do not use it, because their `uri` or module id
* identifies them.
*/
sourceKey?: string | number;
/**
* Size of the avatar.
*/
size?: number;
/**
* Text describing the image for assistive technology. `aria-label` and
* `accessibilityLabel` are treated the same way.
*/
alt?: string;
/**
* Content shown when the image fails to load.
* Receives host `size` so custom content can match the avatar.
*/
fallback?: (props: { size: number }) => React.ReactNode;
style?: StyleProp<ViewStyle>;
/**
* Invoked on load error.
Expand Down Expand Up @@ -68,14 +117,46 @@ export type Props = ViewProps & {
* import { Avatar } from 'react-native-paper';
*
* const MyComponent = () => (
* <Avatar.Image size={24} source={require('../assets/avatar.png')} />
* <Avatar.Image size={24} source={require('../assets/avatar.png')} alt="Jane Doe" />
* );
* export default MyComponent
* ```
*
* Pass `alt` to describe the image to assistive technology — `aria-label` and
* `accessibilityLabel` work the same way. Avatars without a description are
* treated as decorative and skipped by screen readers.
*
* Show another avatar when the image fails to load:
* ```js
* <Avatar.Image
* size={64}
* source={{ uri: user.avatarUrl }}
* alt="Jane Doe"
* fallback={({ size }) => <Avatar.Text size={size} label="JD" />}
* />
* ```
*
* Custom image components should apply the host `style`, `onError` and the
* accessibility props:
* ```js
* <Avatar.Image
* size={64}
* alt="Jane Doe"
* source={({ size, style, onError, ...a11y }) => (
* <CustomImage source={{ uri }} style={style} onError={onError} {...a11y} />
* )}
* fallback={({ size }) => <Avatar.Text size={size} label="JD" />}
* />
* ```
*/
const AvatarImage = ({
size = defaultSize,
size = DEFAULT_SIZE,
source,
sourceKey: sourceKeyProp,
fallback,
alt,
'aria-label': ariaLabel,
accessibilityLabel,
style,
onError,
onLayout,
Expand All @@ -87,41 +168,94 @@ const AvatarImage = ({
testID,
...rest
}: Props) => {
const { colors } = useInternalTheme(themeOverrides);
const { backgroundColor = colors?.primary } = StyleSheet.flatten(style) || {};
const theme = useInternalTheme(themeOverrides);
const { backgroundColor } = StyleSheet.flatten(style) || {};
const { background } = resolveAvatarColors({ theme, backgroundColor });
const imageStyle = {
width: size,
height: size,
borderRadius: cornerFull,
};
const description = alt ?? ariaLabel ?? accessibilityLabel;
const imageA11y =
description === undefined
? { accessible: false as const }
: { alt: description, 'aria-label': description };
const sourceKey = getAvatarImageSourceKey(source, sourceKeyProp);
const [failedSource, setFailedSource] = React.useState<{ key: unknown }>();
const hasError =
failedSource !== undefined && Object.is(failedSource.key, sourceKey);

const handleError: ImageProps['onError'] = (event) => {
setFailedSource({ key: sourceKey });
onError?.(event);
};

const showImage = !(hasError && fallback !== undefined);

const hostA11y = showImage

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fold aria-label / accessibilityLabel into alt here. They currently land on a host that is accessible={false} importantForAccessibility="no", so nothing announces on native - while Avatar.Text and Avatar.Icon in this PR honour both. Your own icon test says why: "View is not an accessibility element by default".

? { accessible: false, importantForAccessibility: 'no' as const }
: description !== undefined
? {
accessible: true,
'aria-label': description,
...getAvatarRole(rest.accessibilityRole),
}
: {};
Comment on lines +196 to +204

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When fallback shows and a description is set, the host gets the label, but the fallback content keeps its own. With <Avatar.Image alt="Broken alt" source={{ uri: 'https://invalid.invalid/a.png' }} fallback={({ size }) => <Avatar.Text size={size} label="BA" />} />, Android has two focusable nodes, "Broken alt" and "BA", so TalkBack stops twice. On web the host is a generic div with aria-label, which screen readers can skip. Give the host an image role and hide the fallback content when the host carries the name:

     : description !== undefined
-      ? { accessible: true, 'aria-label': description }
+      ? { accessible: true, role: 'img' as const, 'aria-label': description }
       : {};
...
-      {!showImage ? fallback({ size }) : null}
+      {!showImage ? (
+        <View aria-hidden={description !== undefined}>
+          {fallback({ size })}
+        </View>
+      ) : null}

(via Claude, verify if it makes sense)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed


return (
<View
style={[
{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor,
borderRadius: cornerFull,
backgroundColor: background,
},
styles.container,
style,
]}
{...hostA11y}
{...rest}
>
{typeof source === 'function' && source({ size })}
{typeof source !== 'function' && (
{showImage && typeof source === 'function'
? source({
size,
style: imageStyle,
onError: handleError,
...imageA11y,
})
: null}
{showImage && typeof source !== 'function' ? (
<Image
testID={testID}
source={source}
style={{ width: size, height: size, borderRadius: size / 2 }}
onError={onError}
style={imageStyle}
onError={handleError}
onLayout={onLayout}
onLoad={onLoad}
onLoadEnd={onLoadEnd}
onLoadStart={onLoadStart}
onProgress={onProgress}
accessibilityIgnoresInvertColors
{...imageA11y}
/>
)}
) : null}
{!showImage ? (
<View aria-hidden={description !== undefined}>
{fallback({ size })}
</View>
) : null}
</View>
);
};

AvatarImage.displayName = 'Avatar.Image';

const styles = StyleSheet.create({
container: {
overflow: 'hidden',
},
});

export default AvatarImage;
Loading
Loading