Skip to content

Avatar

Avatar represents one person, team, or other application identity. Give it a source, an explicit accessible name, and fallback content. It uses the image when available and the fallback when the source is absent or fails.

That is the whole contract. Size, shape, color, typography, borders, rings, presence, grouping, and upload state remain visible Tailwind and application markup.

Avatar.vue

Installation

One command detects Vue, React, or Svelte and writes the matching one-file source into the conventional component directory:

Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.

Terminal
npx klean-ui add avatar

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

There is no initializer, provider, klean-ui.json, class helper, barrel file, Avatar anatomy package, image service, or runtime Klean dependency.

Usage

The fallback is ordinary slot or child content. It is visible only when the source is absent or unavailable.

Vue

CreatorLink.vue
<script setup>
import Avatar from '@/components/ui/avatar/Avatar.vue'

defineProps({ creator: Object })
</script>

<template>
  <a href="/settings/profile" class="flex items-center gap-3">
    <Avatar :src="creator.avatarUrl" alt="" class="size-10 rounded-lg">
      {{ creator.initials }}
    </Avatar>
    <span>{{ creator.name }}</span>
  </a>
</template>

React

CreatorLink.jsx
import Avatar from '@/components/ui/avatar/Avatar.jsx'

export default function CreatorLink({ creator }) {
  return (
    <a href="/settings/profile" className="flex items-center gap-3">
      <Avatar src={creator.avatarUrl} alt="" className="size-10 rounded-lg">
        {creator.initials}
      </Avatar>
      <span>{creator.name}</span>
    </a>
  )
}

Svelte

CreatorLink.svelte
<script>
  import Avatar from '$lib/components/ui/avatar/Avatar.svelte'

  let { creator } = $props()
</script>

<a href="/settings/profile" class="flex items-center gap-3">
  <Avatar src={creator.avatarUrl} alt="" class="size-10 rounded-lg">
    {creator.initials}
  </Avatar>
  <span>{creator.name}</span>
</a>

API

InputDefaultPurpose
src''Native image source. An absent or unavailable source reveals the fallback.
altrequiredAccessible name for standalone identity, or '' when nearby visible text already names it.
default slot / childrenInitials, an icon, or other compact fallback content owned by the application.
class / classNameOrdinary Tailwind merged after the neutral circular baseline.
native image/global attributesloading, decoding, srcset, sizes, IDs, titles, data hooks, and native image event hooks.
element referenceFramework-native access to the current image or fallback element when genuinely needed.

There is no AvatarImage, AvatarFallback, AvatarBadge, AvatarGroup, as, variant, tone, color, size, shape, radius, status, presence, or delay API.

The slot already is the fallback. Tailwind already expresses the visuals. A Button, anchor, or framework Link already expresses interaction. Adding more parts would only rename those tools.

Accessible identity

Use an informative alt when the Avatar stands alone:

vue
<Avatar :src="creator.avatarUrl" :alt="creator.name">
  {{ creator.initials }}
</Avatar>

When visible text next to the Avatar already names the subject, use alt="" so the name is not announced twice:

vue
<a href="/settings/profile" class="flex items-center gap-3">
  <Avatar :src="creator.avatarUrl" alt="">{{ creator.initials }}</Avatar>
  <span>{{ creator.name }}</span>
</a>

The same alt decision applies after image failure. An informative fallback is announced as one image with that name; a decorative fallback stays out of the accessibility tree.

Do not use initials as the accessible name when the complete name is known. “KO” is useful visually; “Kelvin Omereshone” is useful to a screen reader.

Interaction belongs outside

Avatar is not clickable. Put it inside the semantic owner:

  • use a real anchor or Boring Stack Link for a profile or team destination;
  • use a real Button for an account menu or team switcher;
  • keep the Avatar decorative with alt="" when that control already has visible naming text;
  • give an icon-only parent control a complete aria-label that describes its action.

This preserves URLs, modified clicks, keyboard activation, focus rings, disabled state, and browser history without teaching Avatar about routing or commands.

Styling with Tailwind

The default is intentionally neutral: size-10, circular, monochrome fallback, and object-cover. Replace any of it directly:

vue
<Avatar
  :src="team.logoUrl"
  :alt="team.name"
  class="size-16 rounded-xl border border-gray-200 bg-white object-contain p-1"
>
  {{ team.initials }}
</Avatar>

Small comment marks, square team logos, bordered profile images, and high-contrast Hagfish initials are class recipes—not component variants. If a recipe repeats throughout one application, keep a tiny application-owned wrapper or shared class next to that product.

Presence and progress are composition

Presence and upload progress describe application state around identity. They do not change what Avatar is.

AvatarUpload.vue

Use visible text or screen-reader text to name a meaningful presence mark. During upload, the application owns a role="status" region and Spinner; Avatar continues to show the current server value or local preview.

Hagfish and Slipway recipes

These examples come from the actual adoption seams. They prove that one primitive can preserve both products without acquiring either product's vocabulary.

ProductAvatars.vue
Hagfish owns comment color, density, and creator presence. Slipway owns team switching and upload progress. Avatar owns only resilient identity.

Hagfish can replace its Volt Avatar, creator mark, and repeated comment fallback branches while retaining its deterministic color classes and neo-brutalist treatment. Slipway can replace repeated team image-or-initial branches while retaining its quiet sidebar, current-team logic, and profile upload overlay.

Accessibility

  • Always pass alt. Choose a complete informative name or the deliberately empty string based on surrounding visible text.
  • Keep Avatar static and out of the tab order. The surrounding button or link owns focus and interaction.
  • Do not encode presence, role, account state, or notification count through image color alone.
  • Keep fallback text legible at every caller-selected size and preserve contrast in light, dark, and forced-colors modes.
  • Give upload and async state its own visible or screen-reader status text; the image itself is not a live region.
  • Avoid repeating the same identity name in the image, adjacent text, and parent accessible label.

Durable behavior

Identity comes from server data or application state. It is not copied into local storage or query parameters. The same src, alt, and fallback content therefore reproduce the same identity on reload, navigation, SSR, and another device.

Image availability is ephemeral: if a source fails, Avatar shows the supplied fallback; if the application supplies a different source, Avatar tries it. That transient browser outcome is not persisted because it can change independently of the identity record.

The surrounding control owns any durable concern:

  • profile and team destinations stay real URLs in anchors or Inertia Links;
  • current team and creator data stay server-owned;
  • upload progress, optimistic preview, retry, and rollback stay with the upload flow;
  • menu open state remains ephemeral in Menu or Popover;
  • presence comes from the application's realtime or server truth.

When to use

Use Avatar for compact identity in account controls, team switchers, member lists, comments, activity feeds, assignment rows, profile previews, and other places where an image can degrade to a recognizable fallback.

Use it when the source and fallback should occupy the same visual space and share caller-owned styling.

When not to use

  • Use a plain img for editorial images, invoice logos, screenshots, illustrations, or content whose intrinsic ratio matters.
  • Use Badge for status or compact metadata beside identity.
  • Use Spinner and a real status region for upload or loading feedback.
  • Use Button, Menu, or a real Link for interaction around an Avatar.
  • Keep initials generation, deterministic colors, image URL transformation, privacy rules, cropping, and storage in the application.
  • Do not use Avatar as a file upload, image editor, presence service, or account menu.

Complete framework source

Copy, inspect, and change the complete one-file source for your framework.

Vue source

Avatar.vue
<script setup>
import { computed, ref, useAttrs, watch } from 'vue'
import { twMerge } from 'tailwind-merge'

defineOptions({ inheritAttrs: false })

const props = defineProps({
  src: { type: String, default: '' },
  alt: { type: String, required: true }
})

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

const BASE_CLASSES =
  'inline-flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-gray-100 object-cover text-sm font-medium text-gray-700 select-none dark:bg-gray-800 dark:text-gray-300'

const forwardedAttrs = computed(() => {
  const {
    class: _class,
    onError: _onError,
    'data-slot': _dataSlot,
    'data-state': _dataState,
    ...rest
  } = attrs
  return rest
})

const fallbackAttrs = computed(() => {
  const {
    loading: _loading,
    decoding: _decoding,
    crossorigin: _crossorigin,
    referrerpolicy: _referrerpolicy,
    fetchpriority: _fetchpriority,
    sizes: _sizes,
    srcset: _srcset,
    usemap: _usemap,
    ismap: _ismap,
    onLoad: _onLoad,
    ...rest
  } = forwardedAttrs.value
  return rest
})

const hasCallerFallbackSemantics = computed(() =>
  ['role', 'aria-label', 'aria-hidden'].some((name) => name in attrs)
)

const fallbackSemantics = computed(() => {
  if (hasCallerFallbackSemantics.value) return {}
  if (props.alt) return { role: 'img', 'aria-label': props.alt }
  return { 'aria-hidden': 'true' }
})

const finalFallbackAttrs = computed(() => ({
  ...fallbackAttrs.value,
  ...fallbackSemantics.value
}))

watch(
  () => props.src,
  () => {
    failed.value = false
  },
  { flush: 'sync' }
)

function handleError(event) {
  failed.value = true

  const listener = attrs.onError
  if (Array.isArray(listener)) {
    for (const callback of listener) callback(event)
  } else if (typeof listener === 'function') {
    listener(event)
  }
}

defineExpose({ element })
</script>

<template>
  <img
    v-if="props.src && !failed"
    ref="element"
    v-bind="forwardedAttrs"
    data-slot="avatar"
    data-state="image"
    :src="props.src"
    :alt="props.alt"
    :class="twMerge(BASE_CLASSES, attrs.class)"
    @error="handleError"
  />
  <span
    v-else
    ref="element"
    v-bind="finalFallbackAttrs"
    data-slot="avatar"
    data-state="fallback"
    :class="twMerge(BASE_CLASSES, attrs.class)"
  >
    <slot />
  </span>
</template>

React source

Avatar.jsx
import { forwardRef, useEffect, useState } from 'react'
import { twMerge } from 'tailwind-merge'

const BASE_CLASSES =
  'inline-flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-gray-100 object-cover text-sm font-medium text-gray-700 select-none dark:bg-gray-800 dark:text-gray-300'

const Avatar = forwardRef(function Avatar(
  {
    src = '',
    alt,
    children,
    className,
    onError,
    onLoad,
    'data-slot': _dataSlot,
    'data-state': _dataState,
    ...props
  },
  ref
) {
  const [failedSource, setFailedSource] = useState(null)
  const showImage = Boolean(src) && failedSource !== src
  const classes = twMerge(BASE_CLASSES, className)

  useEffect(() => {
    setFailedSource(null)
  }, [src])

  function handleError(event) {
    setFailedSource(src)
    onError?.(event)
  }

  function handleLoad(event) {
    setFailedSource(null)
    onLoad?.(event)
  }

  if (showImage) {
    return (
      <img
        {...props}
        ref={ref}
        data-slot="avatar"
        data-state="image"
        src={src}
        alt={alt}
        className={classes}
        onError={handleError}
        onLoad={handleLoad}
      />
    )
  }

  const {
    loading: _loading,
    decoding: _decoding,
    crossOrigin: _crossOrigin,
    referrerPolicy: _referrerPolicy,
    fetchPriority: _fetchPriority,
    sizes: _sizes,
    srcSet: _srcSet,
    useMap: _useMap,
    isMap: _isMap,
    ...fallbackProps
  } = props
  const hasCallerFallbackSemantics =
    props.role !== undefined ||
    props['aria-label'] !== undefined ||
    props['aria-hidden'] !== undefined
  const fallbackSemantics = hasCallerFallbackSemantics
    ? {}
    : alt
      ? { role: 'img', 'aria-label': alt }
      : { 'aria-hidden': true }

  return (
    <span
      {...fallbackProps}
      {...fallbackSemantics}
      ref={ref}
      data-slot="avatar"
      data-state="fallback"
      className={classes}
    >
      {children}
    </span>
  )
})

export default Avatar

Svelte source

Avatar.svelte
<script>
  import { twMerge } from "tailwind-merge";

  const BASE_CLASSES =
    "inline-flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-gray-100 object-cover text-sm font-medium text-gray-700 select-none dark:bg-gray-800 dark:text-gray-300";
  const IMAGE_ONLY_ATTRIBUTES = new Set([
    "loading",
    "decoding",
    "crossorigin",
    "referrerpolicy",
    "fetchpriority",
    "sizes",
    "srcset",
    "usemap",
    "ismap",
  ]);

  let {
    src = "",
    alt,
    children,
    class: className,
    onerror,
    onload,
    "data-slot": _dataSlot,
    "data-state": _dataState,
    ...props
  } = $props();

  let element = $state();
  let failedSource = $state(null);
  let showImage = $derived(Boolean(src) && failedSource !== src);

  $effect(() => {
    src;
    failedSource = null;
  });

  let fallbackProps = $derived.by(() =>
    Object.fromEntries(
      Object.entries(props).filter(
        ([name]) => !IMAGE_ONLY_ATTRIBUTES.has(name),
      ),
    ),
  );
  let hasCallerFallbackSemantics = $derived(
    props.role !== undefined ||
      props["aria-label"] !== undefined ||
      props["aria-hidden"] !== undefined,
  );
  let fallbackRole = $derived(
    hasCallerFallbackSemantics ? props.role : alt ? "img" : undefined,
  );
  let fallbackLabel = $derived(
    hasCallerFallbackSemantics ? props["aria-label"] : alt || undefined,
  );
  let fallbackHidden = $derived(
    hasCallerFallbackSemantics ? props["aria-hidden"] : alt ? undefined : true,
  );

  function handleError(event) {
    failedSource = src;
    onerror?.(event);
  }

  function handleLoad(event) {
    failedSource = null;
    onload?.(event);
  }

  export function getElement() {
    return element;
  }
</script>

{#if showImage}
  <img
    {...props}
    bind:this={element}
    data-slot="avatar"
    data-state="image"
    {src}
    {alt}
    class={twMerge(BASE_CLASSES, className)}
    onerror={handleError}
    onload={handleLoad}
  />
{:else}
  <span
    {...fallbackProps}
    bind:this={element}
    data-slot="avatar"
    data-state="fallback"
    role={fallbackRole}
    aria-label={fallbackLabel}
    aria-hidden={fallbackHidden}
    class={twMerge(BASE_CLASSES, className)}
  >
    {@render children?.()}
  </span>
{/if}

  • Button and Menu — own account and team-switcher interaction around identity.
  • Badge — adds visible role, status, or count metadata beside an Avatar without changing it.
  • Spinner — supplies the decorative mark inside an app-owned upload status overlay.
  • Card and Table — provide richer member, team, comment, and activity layouts.
  • Popover and Tooltip — add supplementary floating content to the real parent control, never to Avatar itself.

All open source projects are released under the MIT License.