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
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
<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.
Colors
Use color to apply one of the semantic theme colors to the initials background.
Sizes
Rounded
Control the border-radius with rounded. Defaults to full (circular).
Status Indicator
Add a colored status dot with the status attribute.
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.
API Reference
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
src | string | — | Image source URL |
alt | string | — | Alt text; also used to derive initials automatically |
initials | string | — | Explicit 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
| Property | Description |
|---|---|
--avatar-size | Override the avatar width and height |
--avatar-bg | Background color (initials background) |
--avatar-color | Text / icon foreground color |
--avatar-radius | Border radius |
--avatar-border | Border shorthand (e.g. 2px solid) |
--avatar-border-color | Border color (also used for status ring) |
--avatar-font-size | Initials font size |
--avatar-font-weight | Initials font weight |
Accessibility
The avatar component follows WAI-ARIA best practices.
bit-avatar
✅ Screen Readers
- Provide a meaningful
altattribute — it serves as the accessible name and is also used to derive initials automatically. - Initials backgrounds are decorative; the
alttext 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.