Stems UI
Menu

Avatar

An image with a fallback for representing a user.

CNERSM
CNRRAL
+3

First image: idle

Codeapp/demos/public/avatar.tsx
import { clientEntry, type Handle } from 'remix/component'
import {
  Avatar,
  AvatarBadge,
  AvatarFallback,
  AvatarGroup,
  AvatarGroupCount,
  AvatarImage,
  type ImageLoadingStatus,
} from '@stemsui/registry/ui/avatar.tsx'

export const AvatarDemo = clientEntry(import.meta.url, function AvatarDemo(handle: Handle) {
  let status: ImageLoadingStatus = 'idle'

  return () => (
    <div class="flex flex-col items-center gap-6">
      <div class="flex flex-wrap items-center justify-center gap-4">
        <Avatar>
          <AvatarImage
            src="https://github.com/shadcn.png"
            alt="@shadcn"
            onLoadingStatusChange={(next) => {
              status = next
              void handle.update()
            }}
          />
          <AvatarFallback>CN</AvatarFallback>
        </Avatar>

        <Avatar>
          <AvatarImage src="/missing-avatar.png" alt="Broken image" />
          <AvatarFallback>ER</AvatarFallback>
        </Avatar>

        <Avatar size="lg">
          <AvatarImage src="https://github.com/remix-run.png" alt="@remix-run" />
          <AvatarFallback delay={600}>RR</AvatarFallback>
          <AvatarBadge />
        </Avatar>

        <Avatar size="sm">
          <AvatarFallback>SM</AvatarFallback>
        </Avatar>
      </div>

      <AvatarGroup>
        <Avatar>
          <AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
          <AvatarFallback>CN</AvatarFallback>
        </Avatar>
        <Avatar>
          <AvatarImage src="https://github.com/remix-run.png" alt="@remix-run" />
          <AvatarFallback>RR</AvatarFallback>
        </Avatar>
        <Avatar>
          <AvatarFallback>AL</AvatarFallback>
        </Avatar>
        <AvatarGroupCount>+3</AvatarGroupCount>
      </AvatarGroup>

      <p class="text-sm text-muted-foreground" data-testid="avatar-status">
        First image: {status}
      </p>
    </div>
  )
})

Installation

npx shadcn add @stemsui/avatar

Requires the one-time setup. Behavior: stems-ui/avatar.

API reference

The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.

stems-ui/avatar

avatar shows a user's image, with a fallback (initials or an icon) while the image is missing, loading, or broken.

Base UI equivalent: Avatar.

Usage

import * as avatar from 'stems-ui/avatar'

function UserAvatar() {
  return () => (
    <avatar.Context>
      <span class="avatar" mix={avatar.root()}>
        <img src="/ada.png" alt="Ada Lovelace" mix={avatar.image()} />
        <span mix={avatar.fallback({ delay: 600 })}>AL</span>
      </span>
    </avatar.Context>
  )
}

stems-ui/avatar

  • <avatar.Context>: owns the image loading status shared by the parts. No props besides children.
  • avatar.root(): the root element. Adds no attributes; it mirrors Base UI's anatomy.
  • avatar.image(options?): put on an <img>. Reads src from the host.
    • onLoadingStatusChange(status): called with 'loading', then 'loaded' or 'error' ('idle' when src is removed).
    • keepMounted: keep the image visible while it loads in place, instead of hiding it until a preload finishes. Use with loading="lazy" and stack the image over the fallback; hide it with data-loading / data-error (prefer visibility over display: none, which would stop lazy loading). Defaults to false.
  • avatar.fallback(options?)
    • delay: milliseconds to wait before showing the fallback. Defaults to 0.
  • Types: ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'.

Behavior notes

  • Mixins can't add or remove their host, so the image stays in the DOM. By default it is hidden until a new Image() preload of src succeeds (Base UI only renders it then). While not loaded it also has aria-hidden="true", so the fallback names the avatar.
  • The fallback is hidden once the image has loaded, and before its delay has passed.
  • Server rendering: an image with src renders hidden with data-loading, and the fallback renders visible (or hidden, with a delay), so there is no flash of a broken or half-loaded image. Loading starts after hydration.
  • Changing src restarts loading.
  • image() and fallback() also work without <avatar.Context>, but then the fallback can't see the image status and only honors its delay.
  • The hidden attribute needs a stylesheet that doesn't override it (Tailwind's preflight makes [hidden] display: none !important).

Styling hooks

PartAttributeValues
imagedata-loadingPresent while the image is loading.
imagedata-errorPresent when the image failed to load.
imagehiddenPresent until loaded (not keepMounted).
fallbackhiddenPresent when the image has loaded, or during delay.

Base UI's data-starting-style / data-ending-style on the image are not implemented yet.