Summary
Let Avatar.Image derive its loading status from the actually rendered element (its onLoad / onError) instead of always preloading the raw src through an internal new window.Image(). Equivalently: an opt-out for the internal preload, or an externally controlled loadingStatus.
Today AvatarImage calls useImageLoadingStatus(src, elementProps) unconditionally, and that hook does:
const image = new window.Image();
// …
image.src = src; // fetches the RAW src to determine loaded/error
Because of this, render only swaps the visual element — the status is still derived from a side-channel fetch of the raw src. So <Avatar.Image render={<NextImage … />} /> (or any custom image component / optimizer / CDN loader) does not work as one would expect:
- Double fetch — the raw original is downloaded by
new Image() for status, and the optimized/loader version is downloaded by the rendered element.
- Serialized — the element is mounted only when
imageLoadingStatus === 'loaded' (enabled: mounted, if (!mounted) return null), so the rendered element only starts loading after the raw preload finishes.
There's no way to keep the Fallback coordination while avoiding the raw preload: dropping src makes the hook report 'error' immediately, so the image never shows.
Proposed API (either would solve it)
- An opt-out that reads status from the rendered element's own
onLoad/onError instead of new Image(), e.g. <Avatar.Image nativeLoadingStatus={false} /> (name TBD); or
- A controlled mode: accept
loadingStatus (+ keep onLoadingStatusChange) so the consumer can drive it from the rendered element.
The Avatar.Root ↔ Avatar.Fallback coordination and data-*/transition attributes would stay the same — only the source of the status changes.
Examples in other libraries
Not aware of a headless avatar that solves this cleanly — Radix UI's Avatar.Image (which Base UI's Avatar mirrors) has the same internal new Image() preload and the same limitation. The natural status source already exists on the rendered element: next/image (and a plain <img>) expose onLoad/onError, which is exactly what a consumer hand-rolls today to get single, optimized loading:
// what we do instead of Avatar.Image, to avoid the raw preload:
const [loaded, setLoaded] = useState(false);
<Image src={src} fill sizes={sizes} onLoad={() => setLoaded(true)} onError={() => setLoaded(false)} … />
// Fallback shown while !loaded
Motivation
We serve avatars/logos through an image route and use next/image to resize to the actual display size (32–144 px) from a single 1024 px source. We'd like Base UI's Avatar for the Root/Image/Fallback status coordination, rendering next/image via render.
The blocker is the internal raw preload: it downloads the full 1024 px original for every avatar regardless of render (defeating optimization and wasting bandwidth, e.g. long lists of small avatars), and it delays the optimized fetch until the raw one completes. Because the preload is the status mechanism, render alone can't fix it.
Letting the status come from the rendered element would make Avatar.Image compose with next/image, custom loaders, and CDNs — one optimized request, no raw-original download — while keeping the ergonomic Root/Image/Fallback API. This is what forces consumers to reimplement Avatar.Image by hand today.
Summary
Let
Avatar.Imagederive its loading status from the actually rendered element (itsonLoad/onError) instead of always preloading the rawsrcthrough an internalnew window.Image(). Equivalently: an opt-out for the internal preload, or an externally controlledloadingStatus.Today
AvatarImagecallsuseImageLoadingStatus(src, elementProps)unconditionally, and that hook does:Because of this,
renderonly swaps the visual element — the status is still derived from a side-channel fetch of the rawsrc. So<Avatar.Image render={<NextImage … />} />(or any custom image component / optimizer / CDN loader) does not work as one would expect:new Image()for status, and the optimized/loader version is downloaded by the rendered element.imageLoadingStatus === 'loaded'(enabled: mounted,if (!mounted) return null), so the rendered element only starts loading after the raw preload finishes.There's no way to keep the
Fallbackcoordination while avoiding the raw preload: droppingsrcmakes the hook report'error'immediately, so the image never shows.Proposed API (either would solve it)
onLoad/onErrorinstead ofnew Image(), e.g.<Avatar.Image nativeLoadingStatus={false} />(name TBD); orloadingStatus(+ keeponLoadingStatusChange) so the consumer can drive it from the rendered element.The
Avatar.Root↔Avatar.Fallbackcoordination anddata-*/transition attributes would stay the same — only the source of the status changes.Examples in other libraries
Not aware of a headless avatar that solves this cleanly — Radix UI's
Avatar.Image(which Base UI's Avatar mirrors) has the same internalnew Image()preload and the same limitation. The natural status source already exists on the rendered element:next/image(and a plain<img>) exposeonLoad/onError, which is exactly what a consumer hand-rolls today to get single, optimized loading:Motivation
We serve avatars/logos through an image route and use
next/imageto resize to the actual display size (32–144 px) from a single 1024 px source. We'd like Base UI'sAvatarfor theRoot/Image/Fallbackstatus coordination, renderingnext/imageviarender.The blocker is the internal raw preload: it downloads the full 1024 px original for every avatar regardless of
render(defeating optimization and wasting bandwidth, e.g. long lists of small avatars), and it delays the optimized fetch until the raw one completes. Because the preload is the status mechanism,renderalone can't fix it.Letting the status come from the rendered element would make
Avatar.Imagecompose withnext/image, custom loaders, and CDNs — one optimized request, no raw-original download — while keeping the ergonomicRoot/Image/FallbackAPI. This is what forces consumers to reimplementAvatar.Imageby hand today.