Skip to content

Avatar

A circular (or shaped) user representation that renders an image, falls back to initials, and falls back again to a generic person icon. Supports online-presence status indicators, theme colors, and size variants.

Features

  • 🖼️ Three-tier fallback: image → initials → generic person icon
  • 🟢 Status indicator: online, offline, busy, away badge
  • 🎨 6 Theme Colors: primary, secondary, info, success, warning, error
  • 📏 3 Sizes: sm, md, lg
  • 🔵 Rounded variants: sm, md, lg, full (default)
  • 🔧 Customizable via CSS custom properties

Source Code

View Source Code
ts
import { computed, defineComponent, html, onMount, onSlotChange, signal, watch } from '@vielzeug/craftit';

import type { ComponentSize, RoundedSize, ThemeColor } from '../../types';

import { colorThemeMixin, roundedVariantMixin, sizeVariantMixin } from '../../styles';
// ============================================
// Styles
// ============================================
import componentStyles from './avatar.css?inline';

// ============================================
// Types
// ============================================

export type AvatarStatus = 'online' | 'offline' | 'busy' | 'away';

const STATUS_LABELS: Record<AvatarStatus, string> = {
  away: 'Away',
  busy: 'Busy',
  offline: 'Offline',
  online: 'Online',
};

/** Avatar component properties */
export type BitAvatarProps = {
  /** Alt text for the image; also used to derive initials when no `initials` prop is given */
  alt?: string;
  /** Theme color (used for initials background) */
  color?: ThemeColor;
  /** Explicit initials to display when no image is available (e.g. "JD") */
  initials?: string;
  /** Border radius */
  rounded?: RoundedSize | '';
  /** Component size */
  size?: ComponentSize;
  /** Image source URL */
  src?: string;
  /** Online presence indicator */
  status?: AvatarStatus;
};

// ============================================
// Component
// ============================================

/**
 * Displays a user avatar: image → initials → generic fallback icon, in that priority order.
 *
 * @element bit-avatar
 *
 * @attr {string} src - Image source URL
 * @attr {string} alt - Image alt text (also used to derive initials)
 * @attr {string} initials - Explicit initials (up to 2 chars)
 * @attr {string} color - Theme color for initials background
 * @attr {string} size - 'sm' | 'md' | 'lg'
 * @attr {string} rounded - Border radius
 * @attr {string} status - 'online' | 'offline' | 'busy' | 'away'
 *
 * @cssprop --avatar-size - Diameter of the avatar
 * @cssprop --avatar-bg - Background color
 * @cssprop --avatar-color - Text/icon color
 * @cssprop --avatar-radius - Border radius
 * @cssprop --avatar-border - Border shorthand
 * @cssprop --avatar-border-color - Border color (also controls status dot border)
 *
 * @example
 * ```html
 * <bit-avatar src="/jane.jpg" alt="Jane Doe"></bit-avatar>
 * <bit-avatar initials="JD" color="primary"></bit-avatar>
 * <bit-avatar alt="John Smith" status="online"></bit-avatar>
 * ```
 */
export const AVATAR_TAG = defineComponent<BitAvatarProps>({
  props: {
    alt: { default: undefined },
    color: { default: undefined },
    initials: { default: undefined },
    rounded: { default: undefined },
    size: { default: undefined },
    src: { default: undefined },
    status: { default: undefined },
  },
  setup({ props }) {
    const imgFailed = signal(false);

    // Reset stale error state whenever src changes
    watch(props.src, () => {
      imgFailed.value = false;
    });

    // Attach load/error listeners reactively when the img element mounts
    const attachImgListeners = (el: HTMLImageElement | null) => {
      if (!el) return;

      el.addEventListener('error', () => {
        imgFailed.value = true;
      });
      el.addEventListener('load', () => {
        imgFailed.value = false;
      });
    };
    const derivedInitials = computed(() => {
      if (props.initials.value) return props.initials.value.slice(0, 2);

      const alt = props.alt.value;

      if (!alt) return '';

      const parts = alt.trim().split(/\s+/);

      if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();

      return parts[0].slice(0, 2).toUpperCase();
    });
    const showImage = computed(() => !!props.src.value && !imgFailed.value);
    const showInitials = computed(() => !showImage.value && !!derivedInitials.value);
    const showFallback = computed(() => !showImage.value && !showInitials.value);
    // Combines name and status into a single accessible label so AT announces them together
    const avatarLabel = computed(() => {
      const name = (props.alt.value as string | undefined) || null;
      const statusKey = props.status.value as AvatarStatus | undefined;
      const status = statusKey ? STATUS_LABELS[statusKey] : null;

      if (!name && !status) return null;

      if (!name) return `Status: ${status}`;

      if (!status) return name;

      return `${name}, ${status}`;
    });

    return html`
      <span
        class="avatar"
        part="avatar"
        :aria-label="${() => avatarLabel.value}"
        :role="${() => (avatarLabel.value ? 'img' : null)}">
        ${() =>
          props.src.value
            ? html`<img
                ref=${attachImgListeners}
                part="img"
                :src="${() => props.src.value}"
                :alt="${() => props.alt.value || ''}"
                ?hidden="${() => !showImage.value}"
                aria-hidden="true" />`
            : ''}
        ${() =>
          showInitials.value
            ? html`<span class="initials" part="initials" aria-hidden="true">${() => derivedInitials.value}</span>`
            : ''}
        ${() =>
          showFallback.value
            ? html`<span class="icon-fallback" part="fallback" aria-hidden="true">
                <svg
                  xmlns="http://www.w3.org/2000/svg"
                  viewBox="0 0 24 24"
                  fill="currentColor"
                  width="55%"
                  height="55%">
                  <path d="M12 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0 2c-5.33 0-8 2.67-8 4v1h16v-1c0-1.33-2.67-4-8-4Z" />
                </svg>
              </span>`
            : ''}
      </span>
      ${() =>
        props.status.value
          ? html`<span
              class="status"
              part="status"
              :data-status="${() => props.status.value}"
              aria-hidden="true"></span>`
          : ''}
    `;
  },
  styles: [
    colorThemeMixin,
    roundedVariantMixin,
    sizeVariantMixin({
      lg: { fontSize: 'var(--avatar-font-size, var(--text-base))', size: 'var(--avatar-size, var(--size-14))' },
      md: { fontSize: 'var(--avatar-font-size, var(--text-sm))', size: 'var(--avatar-size, var(--size-10))' },
      sm: { fontSize: 'var(--avatar-font-size, var(--text-xs))', size: 'var(--avatar-size, var(--size-7))' },
    }),
    componentStyles,
  ],
  tag: 'bit-avatar',
});

// ============================================
// AvatarGroup
// ============================================

import groupStyles from './avatar-group.css?inline';

/** AvatarGroup component properties */
export type BitAvatarGroupProps = {
  /** Maximum number of avatars to show before showing a +N badge */
  max?: number;
  /** Total count shown in the overflow badge (defaults to the actual hidden count) */
  total?: number;
};

/**
 * Groups multiple `bit-avatar` elements in a stacked, overlapping row.
 *
 * @element bit-avatar-group
 *
 * @attr {number} max - Max visible avatars before overflow badge (default: 5)
 * @attr {number} total - Override the total count displayed in the badge
 *
 * @slot - `bit-avatar` elements
 *
 * @cssprop --avatar-group-overlap - Negative margin creating the overlap (default: -0.75rem)
 *
 * @example
 * ```html
 * <bit-avatar-group max="3">
 *   <bit-avatar src="/a.jpg" alt="Alice"></bit-avatar>
 *   <bit-avatar src="/b.jpg" alt="Bob"></bit-avatar>
 *   <bit-avatar src="/c.jpg" alt="Carol"></bit-avatar>
 *   <bit-avatar src="/d.jpg" alt="Dave"></bit-avatar>
 * </bit-avatar-group>
 * ```
 */
export const AVATAR_GROUP_TAG = defineComponent<BitAvatarGroupProps>({
  props: {
    max: { default: 5 },
    total: { default: undefined },
  },
  setup({ host, props }) {
    const overflowCount = signal(0);

    onMount(() => {
      const updateVisibility = () => {
        const avatars = [...host.querySelectorAll('bit-avatar')];
        const max = Number(props.max.value) || 5;
        const hidden = Math.max(0, avatars.length - max);

        overflowCount.value = props.total.value != null ? Number(props.total.value) - max : hidden;
        avatars.forEach((a, i) => {
          if (i >= max) a.setAttribute('data-avatar-group-hidden', '');
          else a.removeAttribute('data-avatar-group-hidden');
        });
      };

      onSlotChange('default', updateVisibility);
    });

    return html`
      <slot></slot>
      ${() =>
        overflowCount.value > 0
          ? html`<span class="overflow-badge" part="overflow" aria-label="${() => `+${overflowCount.value} more`}">
              +${() => overflowCount.value}
            </span>`
          : ''}
    `;
  },
  styles: [groupStyles],
  tag: 'bit-avatar-group',
});

Basic Usage

html
<bit-avatar src="https://i.pravatar.cc/150?img=1" alt="Jane Doe"></bit-avatar>

<script type="module">
  import '@vielzeug/buildit';
</script>

Fallback to Initials

When no image is provided (or the image fails to load), the avatar displays initials derived from the alt attribute, or explicitly from initials.

PreviewCode
RTL

Colors

Use color to apply one of the semantic theme colors to the initials background.

PreviewCode
RTL

Sizes

PreviewCode
RTL

Rounded

Control the border-radius with rounded. Defaults to full (circular).

PreviewCode
RTL

Status Indicator

Add a colored status dot with the status attribute.

PreviewCode
RTL

Avatar Group

Stack multiple avatars by applying a negative margin via CSS. You can use --avatar-border and --avatar-border-color to add an outline so overlapping avatars remain distinct.

PreviewCode
RTL

API Reference

Attributes

AttributeTypeDefaultDescription
srcstringImage source URL
altstringAlt text; also used to derive initials automatically
initialsstringExplicit initials (e.g. "JD") when no image loads
color'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'Theme color for initials background
size'sm' | 'md' | 'lg''md'Component size
rounded'sm' | 'md' | 'lg' | 'full''full'Border radius
status'online' | 'offline' | 'busy' | 'away'Online presence indicator dot

CSS Custom Properties

PropertyDescription
--avatar-sizeOverride the avatar width and height
--avatar-bgBackground color (initials background)
--avatar-colorText / icon foreground color
--avatar-radiusBorder radius
--avatar-borderBorder shorthand (e.g. 2px solid)
--avatar-border-colorBorder color (also used for status ring)
--avatar-font-sizeInitials font size
--avatar-font-weightInitials font weight

Accessibility

The avatar component follows WAI-ARIA best practices.

bit-avatar

Screen Readers

  • Provide a meaningful alt attribute — it serves as the accessible name and is also used to derive initials automatically.
  • Initials backgrounds are decorative; the alt text provides the accessible name.
  • Status indicator dots are visual only — pair them with a contextual label in the surrounding UI when the status is meaningful.