Components
Avatar
A round image with text/icon fallback. SSR-friendly: we render both layers and let the platform pick the visible one — image on top via onerror="this.style.display='none'" reveals the fallback when the URL 404s.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/avatar.json2. Use it
import { Avatar } from "@/components/ui/avatar"
<Avatar src="/users/mk.jpg" alt="Mehmet K" fallback="MK" />
<Avatar fallback="MK" ariaLabel="Mehmet K" />Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Avatar — shadcn-htmx, htmx v4 + Tailwind v4.
//
// Source of truth (variants):
// repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/avatar.tsx
//
// shadcn upstream uses Radix Avatar which manages a load/error state
// machine (show fallback while image fetches, after fetch fails, etc.).
// For SSR we render both layers — the fallback as a sibling, then the
// <img> on top via absolute positioning. If the image loads, it covers
// the fallback; if it errors, an inline onerror handler hides it,
// revealing the fallback. Zero JS framework needed.
//
// MDN <img>: repos/mdn/files/en-us/web/html/reference/elements/img/index.md
//
// Accessibility:
// - The Avatar root carries the accessible name via the <img>'s alt
// when src is provided; when not, pass `ariaLabel` to the root so AT
// announces "MK avatar" or similar.
// - For purely decorative avatars (next to a name that's already in the
// DOM), pass alt="" so AT skips it.
export type AvatarSize = "sm" | "default" | "lg"
const rootSize: Record<AvatarSize, string> = {
sm: "size-6 [&_[data-slot=avatar-fallback]]:text-xs",
default: "size-8",
lg: "size-10",
}
type AvatarProps = PropsWithChildren<{
size?: AvatarSize
// The image URL. When undefined, only the fallback renders.
src?: string
alt?: string
// Fallback text — typically initials. Ignored when children are provided.
fallback?: string
// Accessible name for the avatar group when there's no img (no alt).
ariaLabel?: string
// Native <img loading>. Avatars are often off-screen in long lists, so
// loading="lazy" defers fetching until near the viewport.
// MDN <img>: .../elements/img/index.md "loading".
loading?: "eager" | "lazy"
// Native <img referrerpolicy>. Avatar src is often a third-party host
// (Gravatar/OAuth/CDN); e.g. "no-referrer" avoids leaking the page URL.
// MDN <img>: .../elements/img/index.md "referrerpolicy".
referrerpolicy?:
| "no-referrer"
| "no-referrer-when-downgrade"
| "origin"
| "origin-when-cross-origin"
| "same-origin"
| "strict-origin"
| "strict-origin-when-cross-origin"
| "unsafe-url"
// Native <img srcset>/<img sizes>. Primarily for the retina "2x" pixel
// density descriptor on small fixed-size avatars; src is the 1x fallback.
// MDN <img>: .../elements/img/index.md "srcset".
srcset?: string
sizes?: string
class?: ClassValue
}>
export function Avatar(props: AvatarProps) {
const { size = "default", src, alt, fallback, ariaLabel, loading, referrerpolicy, srcset, sizes, class: className, children } = props
return (
<span
data-slot="avatar"
data-size={size}
role={!src && (fallback || children) ? "img" : undefined}
aria-label={!src ? ariaLabel ?? fallback : undefined}
class={cn(
"group/avatar relative inline-flex shrink-0 overflow-hidden rounded-full select-none",
rootSize[size],
className,
)}
>
{/* Fallback layer — visible by default; covered by the <img> if it
loads successfully. Children win over `fallback` text. */}
<span
data-slot="avatar-fallback"
class={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground",
)}
>
{children ?? fallback}
</span>
{src && (
<img
src={src}
srcset={srcset}
sizes={sizes}
alt={alt ?? ""}
loading={loading}
referrerpolicy={referrerpolicy}
data-slot="avatar-image"
// When the image fails to load (404, network error), hide it and
// let the fallback layer underneath show through.
onerror="this.style.display='none'"
class="absolute inset-0 aspect-square size-full object-cover"
/>
)}
</span>
)
}
// Optional status badge — pin a coloured dot or icon to the corner.
type AvatarBadgeProps = PropsWithChildren<{ class?: ClassValue }>
export function AvatarBadge(props: AvatarBadgeProps) {
return (
<span
data-slot="avatar-badge"
class={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
props.class,
)}
>
{props.children}
</span>
)
}
// Stack avatars with the typical -space-x overlap.
type AvatarGroupProps = PropsWithChildren<{ class?: ClassValue }>
export function AvatarGroup(props: AvatarGroupProps) {
return (
<div
data-slot="avatar-group"
class={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
props.class,
)}
>
{props.children}
</div>
)
}
// "+N more" counter that matches AvatarGroup sizing.
type AvatarGroupCountProps = PropsWithChildren<{ class?: ClassValue }>
export function AvatarGroupCount(props: AvatarGroupCountProps) {
return (
<div
data-slot="avatar-group-count"
class={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background",
"group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6",
"[&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
props.class,
)}
>
{props.children}
</div>
)
}
1. Save the file
Copy avatar.html into templates/components/.
2. Use it
{% from "components/avatar.html" import avatar %}
{{ avatar(src="/users/mk.jpg", alt="Mehmet K", fallback="MK") }}
{{ avatar(fallback="MK", aria_label="Mehmet K") }}View source
{# Avatar macro — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/avatar.tsx. Native <img> + fallback layer.
Usage:
{% from "components/avatar.html" import avatar %}
{{ avatar(src="/users/mk.jpg", alt="Mehmet K", fallback="MK") }}
{{ avatar(fallback="MK", aria_label="Mehmet K") }} {# no image #} #}
{% macro avatar(src=none, alt=none, fallback=none, size="default", aria_label=none, loading=none, referrerpolicy=none, srcset=none, sizes=none, extra_class="") %}
{%- set sizes = {
"sm": "size-6 [&_[data-slot=avatar-fallback]]:text-xs",
"default": "size-8",
"lg": "size-10"
} -%}
<span data-slot="avatar" data-size="{{ size }}"
{%- if not src and (fallback or aria_label) %} role="img" aria-label="{{ aria_label or fallback }}"{% endif %}
class="group/avatar relative inline-flex shrink-0 overflow-hidden rounded-full select-none {{ sizes[size] }} {{ extra_class }}">
<span data-slot="avatar-fallback"
class="flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">{{ fallback or "" }}</span>
{% if src %}
{# MDN <img>: loading (lazy off-screen), referrerpolicy (3rd-party hosts), srcset/sizes (retina 2x). #}
<img src="{{ src }}" alt="{{ alt or '' }}" data-slot="avatar-image"
{%- if srcset %} srcset="{{ srcset }}"{% endif %}
{%- if sizes %} sizes="{{ sizes }}"{% endif %}
{%- if loading %} loading="{{ loading }}"{% endif %}
{%- if referrerpolicy %} referrerpolicy="{{ referrerpolicy }}"{% endif %}
onerror="this.style.display='none'"
class="absolute inset-0 aspect-square size-full object-cover">
{% endif %}
</span>
{% endmacro %}
1. Save the file
Add avatar.tmpl alongside button.tmpl.
2. Use it
{{template "avatar" (dict "Src" "/users/mk.jpg" "Alt" "Mehmet K" "Fallback" "MK")}}View source
{{/*
Avatar template — shadcn-htmx, htmx v4 + Tailwind v4.
type AvatarArgs struct {
Src, Alt, Fallback, Size, AriaLabel string
// MDN <img>: Loading (lazy off-screen), ReferrerPolicy (3rd-party
// hosts), Srcset/Sizes (retina 2x). All optional.
Loading, ReferrerPolicy, Srcset, Sizes string
}
*/}}
{{define "avatar"}}
{{- $size := or .Size "default" -}}
{{- $sizes := dict
"sm" "size-6 [&_[data-slot=avatar-fallback]]:text-xs"
"default" "size-8"
"lg" "size-10" -}}
<span data-slot="avatar" data-size="{{$size}}"
{{if and (not .Src) (or .Fallback .AriaLabel)}}role="img" aria-label="{{or .AriaLabel .Fallback}}"{{end}}
class="group/avatar relative inline-flex shrink-0 overflow-hidden rounded-full select-none {{index $sizes $size}}">
<span data-slot="avatar-fallback" class="flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">{{.Fallback}}</span>
{{if .Src}}
<img src="{{.Src}}" alt="{{.Alt}}" data-slot="avatar-image"
{{if .Srcset}}srcset="{{.Srcset}}" {{end}}{{if .Sizes}}sizes="{{.Sizes}}" {{end}}{{if .Loading}}loading="{{.Loading}}" {{end}}{{if .ReferrerPolicy}}referrerpolicy="{{.ReferrerPolicy}}" {{end}}
onerror="this.style.display='none'"
class="absolute inset-0 aspect-square size-full object-cover">
{{end}}
</span>
{{end}}
1. Save the file
Drop avatar.ex into lib/my_app_web/components/.
2. Use it
<.avatar src={~p"/users/mk.jpg"} alt="Mehmet K" fallback="MK" />View source
defmodule ShadcnHtmx.Components.Avatar do
@moduledoc """
Avatar — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Native `<img>` on top of a fallback layer. If the image errors, an
inline onerror handler hides it so the fallback shows through.
## Examples
<.avatar src={~p"/users/mk.jpg"} alt="Mehmet K" fallback="MK" />
<.avatar fallback="MK" aria-label="Mehmet K" />
"""
use Phoenix.Component
@sizes %{
"sm" => "size-6 [&_[data-slot=avatar-fallback]]:text-xs",
"default" => "size-8",
"lg" => "size-10"
}
attr :src, :string, default: nil
attr :alt, :string, default: nil
attr :fallback, :string, default: nil
attr :size, :string, default: "default", values: ~w(sm default lg)
attr :"aria-label", :string, default: nil
# MDN <img>: loading (lazy off-screen), referrerpolicy (3rd-party hosts),
# srcset/sizes (retina 2x). All optional, forwarded to the inner <img>.
attr :loading, :string, default: nil
attr :referrerpolicy, :string, default: nil
attr :srcset, :string, default: nil
attr :sizes, :string, default: nil
attr :class, :string, default: nil
def avatar(assigns) do
assigns =
assigns
|> assign(:size_class, Map.fetch!(@sizes, assigns.size))
|> assign(:aria_label, assigns[:"aria-label"])
~H"""
<span
data-slot="avatar"
data-size={@size}
role={if !@src and (@fallback || @aria_label), do: "img"}
aria-label={if !@src, do: @aria_label || @fallback}
class={[
"group/avatar relative inline-flex shrink-0 overflow-hidden rounded-full select-none",
@size_class,
@class
]}
>
<span
data-slot="avatar-fallback"
class="flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground"
>
{@fallback}
</span>
<img
:if={@src}
src={@src}
srcset={@srcset}
sizes={@sizes}
alt={@alt || ""}
loading={@loading}
referrerpolicy={@referrerpolicy}
data-slot="avatar-image"
onerror="this.style.display='none'"
class="absolute inset-0 aspect-square size-full object-cover"
/>
</span>
"""
end
end
1. Save the file
Tailwind utilities only; inline onerror handler.
2. Use it
<span data-slot="avatar" data-size="default"
class="group/avatar relative inline-flex size-8 …">
<span data-slot="avatar-fallback" class="…">MK</span>
<img src="/users/mk.jpg" alt="Mehmet K" onerror="this.style.display='none'" class="…">
</span>View source
<!--
shadcn-htmx — raw HTML avatar snippet.
Mirrors registry/ui/avatar.tsx. Fallback layer + <img> on top; onerror
hides a broken image so the fallback shows through.
-->
<!-- With image. Optional <img> attrs (MDN): loading="lazy" defers off-screen
fetches; referrerpolicy avoids leaking the page URL to 3rd-party hosts;
srcset with an "x" descriptor serves a sharper 2x source on retina. -->
<span data-slot="avatar" data-size="default"
class="group/avatar relative inline-flex shrink-0 overflow-hidden rounded-full select-none size-8">
<span data-slot="avatar-fallback"
class="flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">MK</span>
<img src="/users/mk.jpg" srcset="/users/[email protected] 2x" alt="Mehmet K" data-slot="avatar-image"
loading="lazy" referrerpolicy="no-referrer"
onerror="this.style.display='none'"
class="absolute inset-0 aspect-square size-full object-cover">
</span>
<!-- Fallback-only (no src) -->
<span data-slot="avatar" data-size="default" role="img" aria-label="Mehmet K"
class="group/avatar relative inline-flex shrink-0 overflow-hidden rounded-full select-none size-8">
<span data-slot="avatar-fallback"
class="flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">MK</span>
</span>
<!-- Group (overlapping stack) -->
<div data-slot="avatar-group" class="group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background">
<span data-slot="avatar" data-size="default" role="img" aria-label="A. B" class="group/avatar relative inline-flex size-8 shrink-0 overflow-hidden rounded-full select-none">
<span data-slot="avatar-fallback" class="flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">AB</span>
</span>
<span data-slot="avatar" data-size="default" role="img" aria-label="C. D" class="group/avatar relative inline-flex size-8 shrink-0 overflow-hidden rounded-full select-none">
<span data-slot="avatar-fallback" class="flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">CD</span>
</span>
<div data-slot="avatar-group-count" class="relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background">+3</div>
</div>
Examples
Sizes — sm, default, lg
Three sizes; the fallback text scales automatically.
Avatar size is purely a visual concern. Pick sm for dense lists (comments, mentions), default for headers, and lg for profile-style banner-card placements.
<Avatar size="sm" fallback="MK" />
<Avatar fallback="MK" />
<Avatar size="lg" fallback="MK" />{{ avatar(fallback="MK", size="sm") }}
{{ avatar(fallback="MK") }}
{{ avatar(fallback="MK", size="lg") }}{{template "avatar" (dict "Fallback" "MK" "Size" "sm")}}
{{template "avatar" (dict "Fallback" "MK")}}
{{template "avatar" (dict "Fallback" "MK" "Size" "lg")}}<.avatar size="sm" fallback="MK" />
<.avatar fallback="MK" />
<.avatar size="lg" fallback="MK" /><div class="flex items-center gap-3">
<span data-slot="avatar" data-size="sm" role="img" aria-label="Mehmet K" class="group/avatar relative inline-flex shrink-0 overflow-hidden rounded-full select-none size-6 [&_[data-slot=avatar-fallback]]:text-xs">
<span data-slot="avatar-fallback" class="flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">MK</span>
</span>
<span data-slot="avatar" data-size="default" role="img" aria-label="Mehmet K" class="group/avatar relative inline-flex shrink-0 overflow-hidden rounded-full select-none size-8">
<span data-slot="avatar-fallback" class="flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">MK</span>
</span>
<span data-slot="avatar" data-size="lg" role="img" aria-label="Mehmet K" class="group/avatar relative inline-flex shrink-0 overflow-hidden rounded-full select-none size-10">
<span data-slot="avatar-fallback" class="flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">MK</span>
</span>
</div>Further reading
Fallback — works even when src 404s
Pass a broken URL and the fallback shows through. The inline onerror handler hides the broken img element.
Don't fetch the user's photo on the server to check availability — render the <img> and let the browser handle the error. Faster, cheaper, and the platform already has the loading + error state machine for you.
MKDRAB<Avatar src="/broken.jpg" alt="…" fallback="??" />
<Avatar fallback="MK" ariaLabel="Mehmet K" />{{ avatar(src="/broken.jpg", alt="…", fallback="??") }}
{{ avatar(fallback="MK", aria_label="Mehmet K") }}{{template "avatar" (dict "Src" "/broken.jpg" "Alt" "…" "Fallback" "??")}}
{{template "avatar" (dict "Fallback" "MK" "AriaLabel" "Mehmet K")}}<.avatar src="/broken.jpg" alt="…" fallback="??" />
<.avatar fallback="MK" aria-label="Mehmet K" /><div class="flex items-center gap-3">
<span data-slot="avatar" data-size="default" class="group/avatar relative inline-flex shrink-0 overflow-hidden rounded-full select-none size-8">
<span data-slot="avatar-fallback" class="flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">??</span>
<img src="/this-does-not-exist.jpg" alt="Broken" data-slot="avatar-image" onerror="this.style.display='none'" class="absolute inset-0 aspect-square size-full object-cover"/>
</span>
<span data-slot="avatar" data-size="default" role="img" aria-label="Mehmet K" class="group/avatar relative inline-flex shrink-0 overflow-hidden rounded-full select-none size-8">
<span data-slot="avatar-fallback" class="flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">MK</span>
</span>
<span data-slot="avatar" data-size="default" role="img" aria-label="Dr. R" class="group/avatar relative inline-flex shrink-0 overflow-hidden rounded-full select-none size-8">
<span data-slot="avatar-fallback" class="flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">DR</span>
</span>
<span data-slot="avatar" data-size="default" role="img" aria-label="A. B" class="group/avatar relative inline-flex shrink-0 overflow-hidden rounded-full select-none size-8">
<span data-slot="avatar-fallback" class="flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">AB</span>
</span>
</div>Further reading
Group — overlapping stack with count
Wrap avatars in AvatarGroup; the children overlap via negative margin. Append AvatarGroupCount for the "+N more" tile.
Common pattern for "people on this thread" or "reviewers of this PR". The container uses -space-x-2 to overlap children, and a ring on each child to separate them visually from neighbours.
<AvatarGroup>
<Avatar fallback="AB" ariaLabel="A. B" />
<Avatar fallback="CD" ariaLabel="C. D" />
<Avatar fallback="EF" ariaLabel="E. F" />
<AvatarGroupCount>+3</AvatarGroupCount>
</AvatarGroup><div data-slot="avatar-group" class="group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background">
{{ avatar(fallback="AB") }}
{{ avatar(fallback="CD") }}
{{ avatar(fallback="EF") }}
<div data-slot="avatar-group-count" class="…">+3</div>
</div><div data-slot="avatar-group" class="…">
{{template "avatar" (dict "Fallback" "AB")}}
{{template "avatar" (dict "Fallback" "CD")}}
<div data-slot="avatar-group-count" class="…">+3</div>
</div><div data-slot="avatar-group" class="…">
<.avatar fallback="AB" />
<.avatar fallback="CD" />
<div data-slot="avatar-group-count">+3</div>
</div><div data-slot="avatar-group" class="group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background">
<span data-slot="avatar" data-size="default" role="img" aria-label="A. B" class="group/avatar relative inline-flex shrink-0 overflow-hidden rounded-full select-none size-8">
<span data-slot="avatar-fallback" class="flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">AB</span>
</span>
<span data-slot="avatar" data-size="default" role="img" aria-label="C. D" class="group/avatar relative inline-flex shrink-0 overflow-hidden rounded-full select-none size-8">
<span data-slot="avatar-fallback" class="flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">CD</span>
</span>
<span data-slot="avatar" data-size="default" role="img" aria-label="E. F" class="group/avatar relative inline-flex shrink-0 overflow-hidden rounded-full select-none size-8">
<span data-slot="avatar-fallback" class="flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">EF</span>
</span>
<div data-slot="avatar-group-count" class="relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3">+3</div>
</div>Further reading
API Reference
<Avatar>
| Prop | Type | Default | Description |
|---|---|---|---|
loading | "eager" | "lazy" | — | Native <img> loading attribute, forwarded to the inner image. Use "lazy" to defer fetching avatars that are off-screen in long lists until they near the viewport. Only applies when src is set. |
referrerpolicy | "no-referrer" | "no-referrer-when-downgrade" | "origin" | "origin-when-cross-origin" | "same-origin" | "strict-origin" | "strict-origin-when-cross-origin" | "unsafe-url" | — | Native <img> referrerpolicy, forwarded to the inner image. Useful when src is a third-party host (Gravatar/OAuth/CDN); e.g. "no-referrer" avoids leaking the page URL. Only applies when src is set. |
srcset | string | — | Native <img> srcset, forwarded to the inner image. Primarily for the retina pixel-density case, e.g. "[email protected] 2x"; src acts as the 1x fallback. Only applies when src is set. |
sizes | string | — | Native <img> sizes, forwarded to the inner image. Pair with a width-descriptor srcset; not needed for the common 2x density case. Only applies when src is set. |
src | string | — | Image URL. Omit for fallback-only avatar. |
alt | string | — | Image alt text. Empty for decorative. |
fallback | string | — | Text shown when src is missing or 404s (typically initials). |
size | "sm"|"default"|"lg" | "default" | Visual size. |
ariaLabel | string | — | Accessible name when src is omitted. |
class | string | — | Extra Tailwind classes appended to the root element. |