Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Avatar

A circular user image with an initials or icon fallback.

A circular user image with a graceful fallback. While the image is missing or fails to load, the avatar shows initials (or a projected icon), then swaps to the image once it loads. Sizes are sm / md / lg. A tcn-avatar-group wrapper overlaps its members and rings each one — the styling differs only subtly per platform (the ring tone) and is carried by the cascade.

Installation

npx touchcn add avatar

Usage

<tcn-avatar size="md" src="/avatar.jpg" alt="Avery" initials="AV" />

<tcn-avatar-group>
  <tcn-avatar src="/one.jpg" alt="One" initials="1" />
  <tcn-avatar src="/two.jpg" alt="Two" initials="2" />
  <tcn-avatar initials="+5" />
</tcn-avatar-group>
import { TcnAvatar, TcnAvatarGroup } from '@/components/ui/avatar';

<TcnAvatar size="md" src="/avatar.jpg" alt="Avery" initials="AV" />

<TcnAvatarGroup>
  <TcnAvatar src="/one.jpg" alt="One" initials="1" />
  <TcnAvatar src="/two.jpg" alt="Two" initials="2" />
  <TcnAvatar initials="+5" />
</TcnAvatarGroup>
<script setup lang="ts">
import { TcnAvatar, TcnAvatarGroup } from '@/components/ui/avatar';
</script>

<template>
  <TcnAvatar size="md" src="/avatar.jpg" alt="Avery" initials="AV" />

  <TcnAvatarGroup>
    <TcnAvatar src="/one.jpg" alt="One" initials="1" />
    <TcnAvatar src="/two.jpg" alt="Two" initials="2" />
    <TcnAvatar initials="+5" />
  </TcnAvatarGroup>
</template>

The only behaviour is a boolean load/error flag, so it stays in the copied component — no engine plumbing. Projected content (Angular ng-content, React children) renders as the fallback, so an icon can stand in for missing initials.

Props

Avatar

Prop Type Default Description
src string '' Image URL; falls back on error or while empty.
alt string '' Alt text for the image.
initials string '' Fallback initials shown when there’s no image.
size 'sm' | 'md' | 'lg' 'md' Avatar diameter.
Source · Angular
export * from './tcn-avatar';
import { Component, computed, ElementRef, inject, input, signal } from '@angular/core';
import { cn } from '@touchcn/core';

export type TcnAvatarSize = 'sm' | 'md' | 'lg';

/**
 * Avatar — a circular user image with a graceful fallback. While the image is
 * missing or fails to load it shows the `initials` (or a projected icon), then
 * swaps to the image once it loads. Sizes are `sm` / `md` / `lg`; the styling
 * differs only subtly per platform (the ring in a group, surface tones) and is
 * carried by the cascade.
 *
 * The only behaviour is a boolean load/error flag — trivial enough to keep in
 * the copied component (no engine plumbing).
 */
@Component({
  selector: 'tcn-avatar',
  template: `
    <span class="tcn-avatar-fallback" aria-hidden="true">
      {{ initials() }}<ng-content />
    </span>
    @if (src() && !failed()) {
      <img
        class="tcn-avatar-image"
        [src]="src()"
        [alt]="alt()"
        (error)="failed.set(true)"
      />
    }
  `,
  host: {
    '[class]': 'classes()',
    '[attr.data-size]': 'size()',
  },
})
export class TcnAvatar {
  private readonly authored = inject(ElementRef<HTMLElement>).nativeElement.getAttribute('class') ?? '';

  readonly src = input('');
  readonly alt = input('');
  readonly initials = input('');
  readonly size = input<TcnAvatarSize>('md');

  protected readonly failed = signal(false);

  protected readonly classes = computed(() => cn('tcn-avatar', this.authored));
}

/** Wraps overlapping avatars into a stacked group with an overlap ring. */
@Component({
  selector: 'tcn-avatar-group',
  template: '<ng-content />',
  host: { class: 'tcn-avatar-group' },
})
export class TcnAvatarGroup {}
Source · React
export * from './tcn-avatar';
import { useState } from 'react';
import type { ReactNode } from 'react';
import { cn } from '@touchcn/core';

export type TcnAvatarSize = 'sm' | 'md' | 'lg';

export interface TcnAvatarProps {
  src?: string;
  alt?: string;
  /** Fallback initials shown while the image is missing or failed. */
  initials?: string;
  size?: TcnAvatarSize;
  /** Projected fallback (e.g. an icon) shown alongside/instead of initials. */
  children?: ReactNode;
  className?: string;
  /** Accessible name when the avatar has no text (e.g. an icon fallback). */
  'aria-label'?: string;
}

/**
 * Avatar — a circular user image with a graceful fallback. While the image is
 * missing or fails to load it shows the `initials` (or projected `children`),
 * then swaps to the image once it loads. Sizes are `sm` / `md` / `lg`; the
 * styling differs only subtly per platform (the ring in a group, surface tones)
 * and is carried by the cascade.
 *
 * The only behaviour is a boolean error flag — trivial enough to keep in the
 * copied component (no engine plumbing).
 */
export function TcnAvatar({
  src,
  alt = '',
  initials,
  size = 'md',
  children,
  className,
  'aria-label': ariaLabel,
}: TcnAvatarProps) {
  const [failed, setFailed] = useState(false);

  return (
    <span className={cn('tcn-avatar', className)} data-size={size} aria-label={ariaLabel} role={ariaLabel ? 'img' : undefined}>
      <span className="tcn-avatar-fallback" aria-hidden="true">
        {initials}
        {children}
      </span>
      {src && !failed && (
        <img className="tcn-avatar-image" src={src} alt={alt} onError={() => setFailed(true)} />
      )}
    </span>
  );
}

export interface TcnAvatarGroupProps {
  children?: ReactNode;
  className?: string;
}

/** Wraps overlapping avatars into a stacked group with an overlap ring. */
export function TcnAvatarGroup({ children, className }: TcnAvatarGroupProps) {
  return <span className={cn('tcn-avatar-group', className)}>{children}</span>;
}
Source · Vue
<script lang="ts">
export type TcnAvatarSize = 'sm' | 'md' | 'lg';
</script>

<script setup lang="ts">
import { computed, ref, useAttrs } from 'vue';

/**
 * Avatar — a circular user image with a graceful fallback. While the image is
 * missing or fails to load it shows the `initials` (or projected slot content),
 * then swaps to the image once it loads. Sizes are `sm` / `md` / `lg`; the
 * styling differs only subtly per platform (the ring in a group, surface tones)
 * and is carried by the cascade.
 *
 * The only behaviour is a boolean error flag — trivial enough to keep in the
 * copied component (no engine plumbing).
 */
const props = withDefaults(
  defineProps<{
    src?: string;
    alt?: string;
    /** Fallback initials shown while the image is missing or failed. */
    initials?: string;
    size?: TcnAvatarSize;
  }>(),
  { alt: '', size: 'md' },
);

const attrs = useAttrs();
const failed = ref(false);

// Give the avatar an image role only when it carries an accessible name.
const role = computed(() => (attrs['aria-label'] ? 'img' : undefined));
</script>

<template>
  <span class="tcn-avatar" :data-size="size" :role="role">
    <span class="tcn-avatar-fallback" aria-hidden="true">{{ initials }}<slot /></span>
    <img
      v-if="props.src && !failed"
      class="tcn-avatar-image"
      :src="props.src"
      :alt="alt"
      @error="failed = true"
    />
  </span>
</template>
<script setup lang="ts">
/** Wraps overlapping avatars into a stacked group with an overlap ring. */
</script>

<template>
  <span class="tcn-avatar-group"><slot /></span>
</template>
export { default as TcnAvatar } from './TcnAvatar.vue';
export type { TcnAvatarSize } from './TcnAvatar.vue';
export { default as TcnAvatarGroup } from './TcnAvatarGroup.vue';

Last updated on July 24, 2026

Was this page helpful?