Skip to content
Noksha UIv0.1
Colour theme

display

Avatar

A user image with an initials fallback that only appears once the image is known to have failed.

Examples

Sizes and shapes

The fallback waits until the image is known to have failed, so a cached avatar never flashes initials first.

Ada LovelaceGrace HopperKatherine Johnson

Group

Overlapping stack with a `+n` counter past `max`.

Ada LovelaceGrace HopperKatherine Johnson+2

Own the source

Noksha ships as a package and as copy-paste source. Take the files and they are yours to change. These are read from the generated registry, so they are exactly what the library ships โ€” never a paraphrase of it.

Self-contained apart from @noksha-ui/core. Or let the CLI do it: npx @noksha-ui/cli add avatar writes them, follows the same dependency graph, and fixes up the imports.

import { cx, Slot } from '@noksha-ui/core';
import * as React from 'react';
import type {
  AvatarFallbackProps,
  AvatarGroupProps,
  AvatarImageProps,
  AvatarLoadingStatus,
  AvatarProps,
} from './avatar.types.js';
import {
  avatarFallbackVariants,
  avatarGroupVariants,
  avatarImageVariants,
  avatarVariants,
} from './avatar.variants.js';

interface AvatarContextValue {
  status: AvatarLoadingStatus;
  setStatus: (status: AvatarLoadingStatus) => void;
}

const AvatarContext = React.createContext<AvatarContextValue | null>(null);

function useAvatarContext(part: string): AvatarContextValue {
  const context = React.useContext(AvatarContext);
  if (!context) {
    throw new Error(`[@noksha-ui/react] <Avatar.${part}> must be used inside <Avatar.Root>.`);
  }
  return context;
}

/**
 * A user or entity image with a fallback.
 *
 * ```tsx
 * <Avatar.Root>
 *   <Avatar.Image src={user.avatarUrl} alt={user.name} />
 *   <Avatar.Fallback>{initials(user.name)}</Avatar.Fallback>
 * </Avatar.Root>
 * ```
 */
export const AvatarRoot = React.forwardRef<HTMLSpanElement, AvatarProps>(function AvatarRoot(
  { size = 'md', shape = 'circle', asChild = false, className, ...rest },
  ref,
) {
  const [status, setStatus] = React.useState<AvatarLoadingStatus>('idle');
  const value = React.useMemo(() => ({ status, setStatus }), [status]);

  const Comp = asChild ? Slot : 'span';

  return (
    <AvatarContext.Provider value={value}>
      <Comp
        ref={ref}
        data-status={status}
        className={avatarVariants({ size, shape, className })}
        {...rest}
      />
    </AvatarContext.Provider>
  );
});
AvatarRoot.displayName = 'Avatar.Root';

/**
 * The image stays mounted through its whole lifecycle and fades in, rather than
 * being swapped for the fallback once it resolves.
 *
 * Unmounting the fallback and mounting the image is the obvious approach and it
 * causes a visible flicker on every list of avatars: the fallback paints, the
 * image decodes, the DOM churns. Here the fallback simply sits underneath.
 */
export const AvatarImage = React.forwardRef<HTMLImageElement, AvatarImageProps>(
  function AvatarImage({ className, src, onLoad, onError, onLoadingStatusChange, ...rest }, ref) {
    const { status, setStatus } = useAvatarContext('Image');

    const notify = React.useCallback(
      (next: AvatarLoadingStatus) => {
        setStatus(next);
        onLoadingStatusChange?.(next);
      },
      [setStatus, onLoadingStatusChange],
    );

    // An empty or missing src is a failure, not a pending load โ€” without this the
    // fallback would wait forever for an event that is never coming.
    React.useEffect(() => {
      notify(src ? 'loading' : 'error');
    }, [src, notify]);

    if (!src) return null;

    return (
      // biome-ignore lint/a11y/useAltText: `alt` is required by AvatarImageProps and arrives through the spread
      <img
        ref={ref}
        src={src}
        data-status={status}
        className={avatarImageVariants({
          className: cx(
            'absolute inset-0 transition-opacity duration-(--noksha-duration-normal)',
            status === 'loaded' ? 'opacity-100' : 'opacity-0',
            className,
          ),
        })}
        onLoad={(event) => {
          notify('loaded');
          onLoad?.(event);
        }}
        onError={(event) => {
          notify('error');
          onError?.(event);
        }}
        {...rest}
      />
    );
  },
);
AvatarImage.displayName = 'Avatar.Image';

export const AvatarFallback = React.forwardRef<HTMLSpanElement, AvatarFallbackProps>(
  function AvatarFallback({ delayMs, className, children, ...rest }, ref) {
    const { status } = useAvatarContext('Fallback');
    const [canRender, setCanRender] = React.useState(delayMs === undefined);

    React.useEffect(() => {
      if (delayMs === undefined) return;
      const timer = setTimeout(() => setCanRender(true), delayMs);
      return () => clearTimeout(timer);
    }, [delayMs]);

    if (!canRender || status === 'loaded') return null;

    return (
      <span
        ref={ref}
        // The image above carries the alt text. Announcing the initials too
        // would read the same person's name twice.
        aria-hidden="true"
        className={avatarFallbackVariants({ className })}
        {...rest}
      >
        {children}
      </span>
    );
  },
);
AvatarFallback.displayName = 'Avatar.Fallback';

/**
 * An overlapping row of avatars, clamped to `max` with a `+n` counter.
 *
 * The size is applied by cloning, so the group is the only place a size is
 * written โ€” twelve avatars in a row cannot drift out of step with each other.
 */
export const AvatarGroup = React.forwardRef<HTMLDivElement, AvatarGroupProps>(function AvatarGroup(
  { max, size, spacing = 'normal', className, children, ...rest },
  ref,
) {
  const avatars = React.Children.toArray(children).filter(React.isValidElement);
  const visible = max === undefined ? avatars : avatars.slice(0, max);
  const overflow = avatars.length - visible.length;

  return (
    <div ref={ref} className={avatarGroupVariants({ spacing, className })} {...rest}>
      {visible.map((child) =>
        size ? React.cloneElement(child as React.ReactElement<AvatarProps>, { size }) : child,
      )}
      {overflow > 0 ? (
        <AvatarRoot size={size} aria-label={`${overflow} more`}>
          <span className={avatarFallbackVariants({ className: 'text-[0.85em]' })}>
            +{overflow}
          </span>
        </AvatarRoot>
      ) : null}
    </div>
  );
});
AvatarGroup.displayName = 'Avatar.Group';

export const Avatar = {
  Root: AvatarRoot,
  Image: AvatarImage,
  Fallback: AvatarFallback,
  Group: AvatarGroup,
};

export { avatarVariants };

API reference

Accepted values

AvatarSize
xssmmdlgxl2xl
AvatarShape
circlerounded
AvatarLoadingStatus
idleloadingloadederror

AvatarProps

Also accepts everything from React.HTMLAttributes<HTMLSpanElement>.

PropTypeDescription
sizeAvatarSizeโ€”
shapeAvatarShapeโ€”
asChildbooleanโ€”

AvatarImageProps

Also accepts everything from Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'alt'>.

PropTypeDescription
alt*stringRequired, not optional as it is on `<img>`. An avatar without one is announced as "image" โ€” or, worse, as its file name. Pass the person's name, or `""` when the name is already right next to it and repeating it would be noise.
onLoadingStatusChange(status: AvatarLoadingStatus) => voidCalled as the image resolves. Lets a consumer log broken avatar URLs, which are otherwise invisible โ€” the fallback hides the failure by design.

AvatarFallbackProps

Also accepts everything from React.HTMLAttributes<HTMLSpanElement>.

PropTypeDescription
delayMsnumberHold the fallback back for this many ms. A cached image resolves in a few ms, and showing initials for those few ms produces a flash of the wrong content on every page load. Waiting means the common case renders once.

AvatarGroupProps

Also accepts everything from React.HTMLAttributes<HTMLDivElement>.

PropTypeDescription
maxnumberRender at most this many, then a `+n` counter.
sizeAvatarSizeโ€”
spacing'tight' | 'normal' | 'loose'How far each avatar overlaps the one before it.