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.
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.
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
<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
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
<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
| Input | Default | Purpose |
|---|---|---|
src | '' | Native image source. An absent or unavailable source reveals the fallback. |
alt | required | Accessible name for standalone identity, or '' when nearby visible text already names it. |
| default slot / children | — | Initials, an icon, or other compact fallback content owned by the application. |
class / className | — | Ordinary Tailwind merged after the neutral circular baseline. |
| native image/global attributes | — | loading, decoding, srcset, sizes, IDs, titles, data hooks, and native image event hooks. |
| element reference | — | Framework-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:
<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:
<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-labelthat 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:
<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.
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.
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
imgfor 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
<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
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
<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}
Related components
- 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.