diff --git a/.changeset/dry-cups-reply.md b/.changeset/dry-cups-reply.md
new file mode 100644
index 0000000000..bcd16cd4bb
--- /dev/null
+++ b/.changeset/dry-cups-reply.md
@@ -0,0 +1,58 @@
+---
+"@radix-ui/react-avatar": minor
+"radix-ui": minor
+---
+
+Added a new `mode` prop to `Avatar.Image` to allow for more control over the image rendering.
+
+- `mode="default"` (default): Matches the existing behavior by rendering an `img` element conditionally based on the loading state of an `Image` object constructed after hydration.
+- `mode="native"`: Renders an `img` element unconditionally using its event handlers to update loading state. In `native` mode, you can use CSS to target the image element and style it based on its loading state.
+ ```tsx
+
+
+
+
+
+
+ ```
+ ```css
+ .image-root {
+ position: relative;
+ }
+ .image[data-radix-avatar-loading-status]:not(
+ [data-radix-avatar-loading-status="loaded"]
+ ) {
+ /* hide the element visually until it's loaded to reveal the fallback */
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ }
+ ```
+- `mode="custom"`: Allows for more control over the image rendering. The `render` prop must be provided to render the image. This mode is useful when you want to use a custom image component, such as framework-specific image components or a design-system implementation that uses non-standard props.
+
+ ```tsx
+ (
+ {
+ context.onLoadingStatusChange("loaded");
+ }}
+ onError={() => {
+ context.onLoadingStatusChange("error");
+ }}
+ />
+ )}
+ />
+ ```
diff --git a/apps/storybook/stories/avatar.stories.module.css b/apps/storybook/stories/avatar.stories.module.css
index 95cfae0bbd..d14b0095ee 100644
--- a/apps/storybook/stories/avatar.stories.module.css
+++ b/apps/storybook/stories/avatar.stories.module.css
@@ -1,4 +1,5 @@
.root {
+ position: relative;
/* ensures image/fallback is centered */
display: inline-flex;
align-items: center;
@@ -21,6 +22,13 @@
object-fit: cover;
}
+.image[data-radix-avatar-loading-status]:not([data-radix-avatar-loading-status='loaded']),
+.image[data-loading-status]:not([data-loading-status='loaded']) {
+ opacity: 0;
+ position: absolute;
+ inset: 0;
+}
+
.fallback {
/* ensures content inside the fallback is centered */
width: 100%;
diff --git a/apps/storybook/stories/avatar.stories.tsx b/apps/storybook/stories/avatar.stories.tsx
index d9a87467ee..16156996d7 100644
--- a/apps/storybook/stories/avatar.stories.tsx
+++ b/apps/storybook/stories/avatar.stories.tsx
@@ -8,7 +8,7 @@ const src = 'https://picsum.photos/id/1005/400/400';
const srcAlternative = 'https://picsum.photos/id/1006/400/400';
const srcBroken = 'https://broken.link.com/broken-pic.jpg';
-export const Styled = () => (
+export const Basic = () => (
<>
Without image & with fallback
@@ -50,6 +50,83 @@ export const Styled = () => (
>
);
+export const WithNativeImage = () => (
+ <>
+ With image & with fallback
+
+
+
+ JS
+
+
+
+ With image & with fallback (but broken src)
+
+
+
+
+
+
+
+ Changing image src
+
+ {(src) => (
+
+
+
+ JS
+
+
+ )}
+
+ >
+);
+
+export const WithCustomImage = () => (
+ <>
+ With image & with fallback
+
+ (
+ {
+ if (naturalWidth === 0 || naturalHeight === 0) {
+ context.onLoadingStatusChange('error');
+ } else {
+ context.onLoadingStatusChange('loaded');
+ }
+ }}
+ onError={() => {
+ context.onLoadingStatusChange('error');
+ }}
+ />
+ )}
+ />
+
+
+
+
+ >
+);
+
export const Chromatic = () => (
<>
Without image & with fallback
@@ -114,3 +191,395 @@ function SourceChanger({
}, [sources, src]);
return children(src);
}
+
+// Stripped-down pseudo implementation of Next.js's Image component to
+// demonstrate how to use the AvatarImage with a custom mode. Doesn't really
+// matter if blur/etc works or not, just here to illustrate the concept.
+// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/image-component.tsx
+// MIT license, Copyright (c) 2025 Vercel, Inc.
+interface ImageProps {
+ ref?: React.Ref;
+ sizes: string;
+ src: string;
+ alt: string;
+ onLoad?: (event: React.SyntheticEvent) => void;
+ onError?: (event: React.SyntheticEvent) => void;
+ onLoadingComplete?: (result: { naturalWidth: number; naturalHeight: number }) => void;
+ preload?: boolean;
+ priority?: boolean;
+ loading?: 'eager' | 'lazy';
+ className?: string;
+ quality?: number;
+ width: number;
+ height: number;
+ fill?: boolean;
+ style?: React.CSSProperties;
+ placeholder?: 'empty' | 'blur';
+ blurDataURL?: string;
+}
+
+interface ImageConfig {
+ allSizes: number[];
+ imageSizes: number[];
+ deviceSizes: number[];
+ qualities: number[];
+}
+
+const NextImage: React.FC = (props) => {
+ const onLoadRef = React.useRef(props.onLoad);
+ React.useEffect(() => {
+ onLoadRef.current = props.onLoad;
+ }, [props.onLoad]);
+
+ const onLoadingCompleteRef = React.useRef(props.onLoadingComplete);
+ React.useEffect(() => {
+ onLoadingCompleteRef.current = props.onLoadingComplete;
+ }, [props.onLoadingComplete]);
+
+ const config = React.useMemo(() => {
+ return {
+ allSizes: [32, 48, 64, 96, 128, 256, 384],
+ imageSizes: [32, 48, 64, 96, 128, 256, 384],
+ deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
+ qualities: [75],
+ };
+ }, []);
+
+ const [blurComplete, setBlurComplete] = React.useState(false);
+ const { props: imgAttributes, meta: imgMeta } = getImgProps(props, {
+ imgConf: config,
+ blurComplete,
+ });
+ const { loading, width, height, style, sizes, srcSet, ...rest } = imgAttributes;
+ const { placeholder, fill } = imgMeta;
+
+ return (
+ <>
+ {/* oxlint-disable-next-line jsx-a11y/alt-text */}
+ {
+ const currentImage = event.currentTarget;
+ handleLoading(
+ currentImage,
+ placeholder,
+ onLoadRef,
+ onLoadingCompleteRef,
+ setBlurComplete,
+ );
+ }}
+ onError={(event) => {
+ if (placeholder !== 'empty') {
+ // If the real image fails to load, this will still remove the placeholder.
+ setBlurComplete(true);
+ }
+ if (props.onError) {
+ props.onError(event);
+ }
+ }}
+ />
+ {imgMeta.preload ? (
+
+ ) : null}
+ >
+ );
+};
+
+function getImgProps(
+ {
+ src,
+ sizes,
+ priority = false,
+ preload = false,
+ loading,
+ className,
+ quality = 100,
+ width,
+ height,
+ fill = false,
+ style,
+ onLoad,
+ onLoadingComplete,
+ placeholder = 'empty',
+ blurDataURL,
+ ...rest
+ }: ImageProps,
+ state: {
+ imgConf: ImageConfig;
+ blurComplete: boolean | undefined;
+ },
+): {
+ props: React.ComponentProps<'img'>;
+ meta: {
+ placeholder: 'empty' | 'blur';
+ preload: boolean;
+ fill: boolean;
+ };
+} {
+ const { imgConf: config, blurComplete } = state;
+
+ let blurWidth: number | undefined;
+ let blurHeight: number | undefined;
+
+ const isLazy = !priority && !preload && (loading === 'lazy' || typeof loading === 'undefined');
+ const imgStyle = Object.assign(
+ fill
+ ? {
+ position: 'absolute',
+ height: '100%',
+ width: '100%',
+ left: 0,
+ top: 0,
+ right: 0,
+ bottom: 0,
+ objectFit: 'cover',
+ objectPosition: '50% 50%',
+ }
+ : {},
+ style,
+ );
+
+ const backgroundImage =
+ !blurComplete && placeholder !== 'empty'
+ ? placeholder === 'blur'
+ ? `url("data:image/svg+xml;charset=utf-8,${getImageBlurSvg({
+ widthInt: width,
+ heightInt: height,
+ blurWidth,
+ blurHeight,
+ blurDataURL: blurDataURL || '',
+ objectFit: imgStyle.objectFit,
+ })}")`
+ : `url("${placeholder}")` // assume `data:image/`
+ : null;
+
+ const backgroundSize = !['contain', 'cover', 'fill'].includes(imgStyle.objectFit!)
+ ? imgStyle.objectFit
+ : imgStyle.objectFit === 'fill'
+ ? '100% 100%' // the background-size equivalent of `fill`
+ : 'cover';
+
+ const placeholderStyle: React.CSSProperties = backgroundImage
+ ? {
+ backgroundSize,
+ backgroundPosition: imgStyle.objectPosition || '50% 50%',
+ backgroundRepeat: 'no-repeat',
+ backgroundImage,
+ }
+ : {};
+
+ const imgAttributes = generateImgAttrs({
+ config,
+ src,
+ width,
+ quality,
+ sizes,
+ loader: ({ src, quality, width }) => `${src}?w=${width}&q=${quality}`,
+ });
+
+ const loadingFinal = isLazy ? 'lazy' : loading;
+
+ const props: React.ComponentProps<'img'> = {
+ ...rest,
+ loading: loadingFinal,
+ width,
+ height,
+ className,
+ style: { ...imgStyle, ...placeholderStyle },
+ sizes: imgAttributes.sizes,
+ srcSet: imgAttributes.srcSet,
+ src: imgAttributes.src,
+ };
+
+ return { props, meta: { placeholder, preload: preload || priority, fill } };
+}
+
+function getImageBlurSvg({
+ widthInt,
+ heightInt,
+ blurWidth,
+ blurHeight,
+ blurDataURL,
+ objectFit,
+}: {
+ widthInt?: number;
+ heightInt?: number;
+ blurWidth?: number;
+ blurHeight?: number;
+ blurDataURL: string;
+ objectFit?: string;
+}): string {
+ const std = 20;
+ const svgWidth = blurWidth ? blurWidth * 40 : widthInt;
+ const svgHeight = blurHeight ? blurHeight * 40 : heightInt;
+
+ const viewBox = svgWidth && svgHeight ? `viewBox='0 0 ${svgWidth} ${svgHeight}'` : '';
+ const preserveAspectRatio = viewBox
+ ? 'none'
+ : objectFit === 'contain'
+ ? 'xMidYMid'
+ : objectFit === 'cover'
+ ? 'xMidYMid slice'
+ : 'none';
+
+ return `%3Csvg xmlns='http://www.w3.org/2000/svg' ${viewBox}%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='${std}'/%3E%3CfeColorMatrix values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 100 -1' result='s'/%3E%3CfeFlood x='0' y='0' width='100%25' height='100%25'/%3E%3CfeComposite operator='out' in='s'/%3E%3CfeComposite in2='SourceGraphic'/%3E%3CfeGaussianBlur stdDeviation='${std}'/%3E%3C/filter%3E%3Cimage width='100%25' height='100%25' x='0' y='0' preserveAspectRatio='${preserveAspectRatio}' style='filter: url(%23b);' href='${blurDataURL}'/%3E%3C/svg%3E`;
+}
+
+function generateImgAttrs({
+ config,
+ src,
+ width,
+ quality,
+ sizes,
+ loader,
+}: {
+ config: ImageConfig;
+ src: string;
+ width: number;
+ quality: number;
+ sizes: string;
+ loader: (props: { config: ImageConfig; src: string; quality: number; width: number }) => string;
+}) {
+ const { widths, kind } = getWidths(config, width, sizes);
+ const last = widths.length - 1;
+ return {
+ sizes: !sizes && kind === 'w' ? '100vw' : sizes,
+ srcSet: widths
+ .map(
+ (w, i) =>
+ `${loader({ config, src, quality, width: w })} ${kind === 'w' ? w : i + 1}${kind}`,
+ )
+ .join(', '),
+ src: loader({ config, src, quality, width: widths[last]! }),
+ };
+}
+
+function getWidths(
+ config: { deviceSizes: number[]; imageSizes: number[]; qualities: number[]; allSizes: number[] },
+ width: number | undefined,
+ sizes: string | undefined,
+): { widths: number[]; kind: 'w' | 'x' } {
+ const { deviceSizes, allSizes } = config;
+ if (sizes) {
+ // Find all the "vw" percent sizes used in the sizes prop
+ const viewportWidthRe = /(^|\s)(1?\d?\d)vw/g;
+ const percentSizes = [];
+ for (let match; (match = viewportWidthRe.exec(sizes)); match) {
+ percentSizes.push(parseInt(match[2]!));
+ }
+ if (percentSizes.length) {
+ const smallestRatio = Math.min(...percentSizes) * 0.01;
+ return {
+ widths: allSizes.filter((s) => s >= deviceSizes[0]! * smallestRatio),
+ kind: 'w',
+ };
+ }
+ return { widths: allSizes, kind: 'w' };
+ }
+ if (typeof width !== 'number') {
+ return { widths: deviceSizes, kind: 'w' };
+ }
+
+ const widths = [
+ ...new Set(
+ // > This means that most OLED screens that say they are 3x resolution,
+ // > are actually 3x in the green color, but only 1.5x in the red and
+ // > blue colors. Showing a 3x resolution image in the app vs a 2x
+ // > resolution image will be visually the same, though the 3x image
+ // > takes significantly more data. Even true 3x resolution screens are
+ // > wasteful as the human eye cannot see that level of detail without
+ // > something like a magnifying glass.
+ // https://blog.twitter.com/engineering/en_us/topics/infrastructure/2019/capping-image-fidelity-on-ultra-high-resolution-devices.html
+ [width, width * 2 /*, width * 3*/].map(
+ (w) => allSizes.find((p) => p >= w) || allSizes[allSizes.length - 1]!,
+ ),
+ ),
+ ];
+ return { widths, kind: 'x' };
+}
+
+// See https://stackoverflow.com/q/39777833/266535 for why we use this ref
+// handler instead of the img's onLoad attribute.
+function handleLoading(
+ img: HTMLImageElement,
+ placeholder: 'empty' | 'blur',
+ onLoadRef: React.RefObject<((event: React.SyntheticEvent) => void) | undefined>,
+ onLoadingCompleteRef: React.RefObject<
+ ((result: { naturalWidth: number; naturalHeight: number }) => void) | undefined
+ >,
+ setBlurComplete: (b: boolean) => void,
+) {
+ const src = img?.src;
+ if (!img || (img as any)['data-loaded-src'] === src) {
+ return;
+ }
+ (img as any)['data-loaded-src'] = src;
+ const p = 'decode' in img ? img.decode() : Promise.resolve();
+ p.catch(() => {}).then(() => {
+ if (!img.parentElement || !img.isConnected) {
+ // Exit early in case of race condition:
+ // - onload() is called
+ // - decode() is called but incomplete
+ // - unmount is called
+ // - decode() completes
+ return;
+ }
+ if (placeholder !== 'empty') {
+ setBlurComplete(true);
+ }
+ if (onLoadRef?.current) {
+ // Since we don't have the SyntheticEvent here,
+ // we must create one with the same shape.
+ // See https://reactjs.org/docs/events.html
+ const event = new Event('load');
+ Object.defineProperty(event, 'target', { writable: false, value: img });
+ let prevented = false;
+ let stopped = false;
+ onLoadRef.current({
+ ...event,
+ nativeEvent: event,
+ currentTarget: img,
+ target: img,
+ isDefaultPrevented: () => prevented,
+ isPropagationStopped: () => stopped,
+ persist: () => {},
+ preventDefault: () => {
+ prevented = true;
+ event.preventDefault();
+ },
+ stopPropagation: () => {
+ stopped = true;
+ event.stopPropagation();
+ },
+ });
+ }
+ if (onLoadingCompleteRef?.current) {
+ onLoadingCompleteRef.current(img);
+ }
+ });
+}
diff --git a/packages/react/avatar/package.json b/packages/react/avatar/package.json
index d226bbd70c..5d3f76acb6 100644
--- a/packages/react/avatar/package.json
+++ b/packages/react/avatar/package.json
@@ -35,10 +35,11 @@
"build": "radix-build"
},
"dependencies": {
+ "@radix-ui/primitive": "workspace:*",
+ "@radix-ui/react-compose-refs": "workspace:*",
"@radix-ui/react-context": "workspace:*",
"@radix-ui/react-primitive": "workspace:*",
"@radix-ui/react-use-callback-ref": "workspace:*",
- "@radix-ui/react-use-is-hydrated": "workspace:*",
"@radix-ui/react-use-layout-effect": "workspace:*"
},
"devDependencies": {
diff --git a/packages/react/avatar/src/avatar.test.tsx b/packages/react/avatar/src/avatar.test.tsx
index 28d52accc1..8e0e2c5f5e 100644
--- a/packages/react/avatar/src/avatar.test.tsx
+++ b/packages/react/avatar/src/avatar.test.tsx
@@ -1,6 +1,6 @@
import { axe } from 'vitest-axe';
import type { RenderResult } from '@testing-library/react';
-import { cleanup, render, waitFor } from '@testing-library/react';
+import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
import * as Avatar from './avatar';
import * as React from 'react';
import { renderToString } from 'react-dom/server';
@@ -402,6 +402,83 @@ describe('given an Avatar with multiple images (development)', () => {
});
});
+describe('given an Avatar with a native image', () => {
+ afterEach(cleanup);
+
+ const ui = (showImage: boolean) => (
+
+ {FALLBACK_TEXT}
+ {showImage ? : null}
+
+ );
+
+ it('renders the image element unconditionally', () => {
+ const rendered = render(ui(true));
+ expect(rendered.queryByRole('img')).toBeInTheDocument();
+ });
+
+ it('resolves to "loaded" for a cached image', async () => {
+ // Mirror a cached image
+ const completeSpy = vi
+ .spyOn(window.HTMLImageElement.prototype, 'complete', 'get')
+ .mockReturnValue(true);
+ const naturalWidthSpy = vi
+ .spyOn(window.HTMLImageElement.prototype, 'naturalWidth', 'get')
+ .mockReturnValue(300);
+
+ const rendered = render(ui(true));
+ const image = rendered.getByRole('img');
+
+ await waitFor(() =>
+ expect(image).toHaveAttribute('data-radix-avatar-loading-status', 'loaded'),
+ );
+ expect(rendered.queryByText(FALLBACK_TEXT)).not.toBeInTheDocument();
+
+ completeSpy.mockRestore();
+ naturalWidthSpy.mockRestore();
+ });
+
+ it('resolves to "error" for a cached image with no natural size', async () => {
+ const completeSpy = vi
+ .spyOn(window.HTMLImageElement.prototype, 'complete', 'get')
+ .mockReturnValue(true);
+ const naturalWidthSpy = vi
+ .spyOn(window.HTMLImageElement.prototype, 'naturalWidth', 'get')
+ .mockReturnValue(0);
+
+ const rendered = render(ui(true));
+ const image = rendered.getByRole('img');
+
+ await waitFor(() => expect(image).toHaveAttribute('data-radix-avatar-loading-status', 'error'));
+ expect(rendered.queryByText(FALLBACK_TEXT)).toBeInTheDocument();
+
+ completeSpy.mockRestore();
+ naturalWidthSpy.mockRestore();
+ });
+
+ it('should render the fallback again after a loaded image unmounts', async () => {
+ const completeSpy = vi
+ .spyOn(window.HTMLImageElement.prototype, 'complete', 'get')
+ .mockReturnValue(true);
+ const naturalWidthSpy = vi
+ .spyOn(window.HTMLImageElement.prototype, 'naturalWidth', 'get')
+ .mockReturnValue(300);
+
+ const rendered = render(ui(true));
+ const image = rendered.getByRole('img');
+
+ fireEvent.load(image);
+ await waitFor(() => expect(rendered.queryByText(FALLBACK_TEXT)).not.toBeInTheDocument());
+
+ rendered.rerender(ui(false));
+ expect(rendered.queryByRole('img')).not.toBeInTheDocument();
+ expect(rendered.queryByText(FALLBACK_TEXT)).toBeInTheDocument();
+
+ completeSpy.mockRestore();
+ naturalWidthSpy.mockRestore();
+ });
+});
+
class MockImage extends EventTarget {
_src: string = '';
diff --git a/packages/react/avatar/src/avatar.tsx b/packages/react/avatar/src/avatar.tsx
index c5c5b816c0..2d0b554274 100644
--- a/packages/react/avatar/src/avatar.tsx
+++ b/packages/react/avatar/src/avatar.tsx
@@ -1,10 +1,10 @@
import * as React from 'react';
-import { createContextScope } from '@radix-ui/react-context';
+import { createContextScope, type Scope } from '@radix-ui/react-context';
import { useCallbackRef } from '@radix-ui/react-use-callback-ref';
import { useLayoutEffect } from '@radix-ui/react-use-layout-effect';
import { Primitive } from '@radix-ui/react-primitive';
-
-import type { Scope } from '@radix-ui/react-context';
+import { composeEventHandlers } from '@radix-ui/primitive';
+import { useComposedRefs } from '@radix-ui/react-compose-refs';
/* -------------------------------------------------------------------------------------------------
* Avatar
@@ -65,45 +65,191 @@ const IMAGE_NAME = 'AvatarImage';
type AvatarImageElement = React.ComponentRef;
type PrimitiveImageProps = React.ComponentPropsWithoutRef;
-interface AvatarImageProps extends PrimitiveImageProps {
+type AvatarImageMode = 'default' | 'native' | 'custom';
+interface AvatarImageCommonProps extends PrimitiveImageProps {
onLoadingStatusChange?: (status: ImageLoadingStatus) => void;
}
+interface AvatarImageDefaultModeProps extends AvatarImageCommonProps {
+ mode?: Exclude;
+ render?: never;
+}
+interface AvatarImageCustomModeProps extends AvatarImageCommonProps {
+ mode: 'custom';
+ render: (args: {
+ props: PrimitiveImageProps;
+ ref: React.Ref | undefined;
+ context: {
+ loadingStatus: ImageLoadingStatus;
+ onLoadingStatusChange: (status: ImageLoadingStatus) => void;
+ };
+ }) => React.ReactNode;
+}
+
+type AvatarImageProps = AvatarImageDefaultModeProps | AvatarImageCustomModeProps;
const AvatarImage = React.forwardRef(
(props: ScopedProps, forwardedRef) => {
- const { __scopeAvatar, src, onLoadingStatusChange, ...imageProps } = props;
- const context = useAvatarContext(IMAGE_NAME, __scopeAvatar);
- useUpdateImageCount(context.setImageCount);
-
- const imageLoadingStatus = useImageLoadingStatus(src, {
- referrerPolicy: imageProps.referrerPolicy,
- crossOrigin: imageProps.crossOrigin,
- loadingStatus: context.imageLoadingStatus,
- setLoadingStatus: context.setImageLoadingStatus,
- });
- const handleLoadingStatusChange = useCallbackRef((status: ImageLoadingStatus) => {
- onLoadingStatusChange?.(status);
- });
-
- const loadingStatusRef = React.useRef(imageLoadingStatus);
-
- useLayoutEffect(() => {
- const previousLoadingStatus = loadingStatusRef.current;
- loadingStatusRef.current = imageLoadingStatus;
-
- if (imageLoadingStatus !== previousLoadingStatus) {
- handleLoadingStatusChange(imageLoadingStatus);
- }
- }, [imageLoadingStatus, handleLoadingStatusChange]);
-
- return imageLoadingStatus === 'loaded' ? (
-
- ) : null;
+ const { mode = 'default', render, ...imageProps } = props;
+ switch (mode) {
+ case 'default':
+ return ;
+ case 'native':
+ return ;
+ case 'custom':
+ if (render == null) {
+ throw new Error('AvatarImage: `render` prop is required when mode is `custom`');
+ }
+
+ return ;
+
+ default:
+ mode satisfies never;
+ return ;
+ }
},
);
AvatarImage.displayName = IMAGE_NAME;
+const AvatarImageImpl = React.forwardRef<
+ AvatarImageElement,
+ Omit
+>((props: ScopedProps>, forwardedRef) => {
+ const { __scopeAvatar, src, onLoadingStatusChange, ...imageProps } = props;
+ const context = useAvatarContext(IMAGE_NAME, __scopeAvatar);
+ useUpdateImageCount(context.setImageCount);
+
+ const imageLoadingStatus = useImageLoadingStatus(src, {
+ referrerPolicy: imageProps.referrerPolicy,
+ crossOrigin: imageProps.crossOrigin,
+ loadingStatus: context.imageLoadingStatus,
+ setLoadingStatus: context.setImageLoadingStatus,
+ });
+ const handleLoadingStatusChange = useCallbackRef((status: ImageLoadingStatus) => {
+ onLoadingStatusChange?.(status);
+ });
+
+ const loadingStatusRef = React.useRef(imageLoadingStatus);
+
+ useLayoutEffect(() => {
+ const previousLoadingStatus = loadingStatusRef.current;
+ loadingStatusRef.current = imageLoadingStatus;
+
+ if (imageLoadingStatus !== previousLoadingStatus) {
+ handleLoadingStatusChange(imageLoadingStatus);
+ }
+ }, [imageLoadingStatus, handleLoadingStatusChange]);
+
+ return imageLoadingStatus === 'loaded' ? (
+
+ ) : null;
+});
+
+AvatarImageImpl.displayName = IMAGE_NAME + 'Impl';
+
+const AvatarImageNative = React.forwardRef<
+ AvatarImageElement,
+ Omit
+>((props: ScopedProps>, forwardedRef) => {
+ const { __scopeAvatar, src, onLoadingStatusChange, ...imageProps } = props;
+ const context = useAvatarContext(IMAGE_NAME, __scopeAvatar);
+ useUpdateImageCount(context.setImageCount);
+
+ const { imageLoadingStatus, setImageLoadingStatus } = context;
+ const loadingStatusRef = React.useRef(imageLoadingStatus);
+
+ const handleLoadingStatusChange = useCallbackRef((status: ImageLoadingStatus) => {
+ onLoadingStatusChange?.(status);
+ });
+
+ useLayoutEffect(() => {
+ const previousLoadingStatus = loadingStatusRef.current;
+ loadingStatusRef.current = imageLoadingStatus;
+ if (imageLoadingStatus !== previousLoadingStatus) {
+ handleLoadingStatusChange(imageLoadingStatus);
+ }
+ }, [imageLoadingStatus, handleLoadingStatusChange]);
+
+ return (
+ {
+ if (!src) {
+ // an image without an src will never update its loading status from `error`
+ return;
+ }
+
+ if (node) {
+ setImageLoadingStatus(getImageLoadingStatus(node));
+ }
+
+ if (Number.parseInt(React.version, 10) >= 19) {
+ return () => {
+ setImageLoadingStatus('idle');
+ };
+ } else if (!node) {
+ setImageLoadingStatus('idle');
+ }
+ },
+ [setImageLoadingStatus, src],
+ ),
+ )}
+ src={src}
+ onError={composeEventHandlers(imageProps.onError, () =>
+ context.setImageLoadingStatus('error'),
+ )}
+ onLoad={composeEventHandlers(imageProps.onLoad, (event) => {
+ if (!src) {
+ // an image without an src will never update its loading status from `error`
+ return;
+ }
+
+ return context.setImageLoadingStatus(getImageLoadingStatus(event.currentTarget));
+ })}
+ />
+ );
+});
+AvatarImageNative.displayName = IMAGE_NAME + 'Native';
+
+const AvatarImageCustom = React.forwardRef<
+ AvatarImageElement,
+ Omit
+>((props: ScopedProps>, forwardedRef) => {
+ const { __scopeAvatar, render, onLoadingStatusChange, ...imageProps } = props;
+ const context = useAvatarContext(IMAGE_NAME, __scopeAvatar);
+ useUpdateImageCount(context.setImageCount);
+
+ const handleLoadingStatusChange = useCallbackRef((status: ImageLoadingStatus) => {
+ onLoadingStatusChange?.(status);
+ });
+
+ const { imageLoadingStatus, setImageLoadingStatus } = context;
+ const loadingStatusRef = React.useRef(imageLoadingStatus);
+
+ useLayoutEffect(() => {
+ const previousLoadingStatus = loadingStatusRef.current;
+ loadingStatusRef.current = imageLoadingStatus;
+ if (imageLoadingStatus !== previousLoadingStatus) {
+ handleLoadingStatusChange(imageLoadingStatus);
+ }
+ }, [imageLoadingStatus, handleLoadingStatusChange]);
+
+ return render({
+ context: {
+ loadingStatus: imageLoadingStatus,
+ onLoadingStatusChange: setImageLoadingStatus,
+ },
+ props: imageProps,
+ ref: forwardedRef,
+ });
+});
+
+AvatarImageCustom.displayName = IMAGE_NAME + 'Custom';
+
/* -------------------------------------------------------------------------------------------------
* AvatarFallback
* -----------------------------------------------------------------------------------------------*/
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5715fa5193..80cc20ae61 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -475,6 +475,12 @@ importers:
packages/react/avatar:
dependencies:
+ '@radix-ui/primitive':
+ specifier: workspace:*
+ version: link:../../core/primitive
+ '@radix-ui/react-compose-refs':
+ specifier: workspace:*
+ version: link:../compose-refs
'@radix-ui/react-context':
specifier: workspace:*
version: link:../context
@@ -484,9 +490,6 @@ importers:
'@radix-ui/react-use-callback-ref':
specifier: workspace:*
version: link:../use-callback-ref
- '@radix-ui/react-use-is-hydrated':
- specifier: workspace:*
- version: link:../use-is-hydrated
'@radix-ui/react-use-layout-effect':
specifier: workspace:*
version: link:../use-layout-effect