Avatar
An image element with a fallback for representing the user.
Basic
A basic avatar with an image and fallback initials.
Alex Rivera
@alexrivera
Sizes
Avatars come in multiple sizes.
XS
SM
MD
LG
XL
XXL
Shapes
Avatars can be circular or square.
Circle
Square
Fallback
When an image fails to load or is not provided, the fallback is displayed.
Broken URL
AB
No Image
MK
Square Fallback
Avatar Group
Avatars can be stacked to show a group of users.
U2
U3+3With Status Badge
Avatars can include a status indicator.
VC
GHFreeAvatar
User identity display with fallback initials.
Install with the Proa CLI
$ proa ui add avatarInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →recipe.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Avatar recipe — typed root axes and named image/fallback slots.
//!
//! A nested fallback follows the root Avatar's `data-size` through a named
//! group. Its legacy local size still provides sensible standalone rendering,
//! while the root remains authoritative when the parts are composed.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
mod base {
use super::*;
pub const ROOT: &str = tw_join!(
"group/avatar",
"relative",
"flex",
"shrink-0",
"overflow-hidden"
Avatar source
Browse and copy the reviewed source included with this Free component.
avatar.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Avatar component implementations
//!
//! Renders avatar images with fallback support. The TypeScript hydration
//! code handles image load errors and shows the fallback when needed.
use super::recipe::{AvatarRecipeProps, AvatarShape, AvatarSize, AVATAR_RECIPE};
use super::AVATAR_ANATOMY;
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_macros::html_sync;
/// Avatar root component - container for image and fallback
///
/// On SSR, this renders a container with the image and fallback.
/// The TypeScript code handles showing the fallback when the image fails to load.
pub struct Avatar<Image = (), Fallback = ()> {
/// Size variant
pub size: AvatarSize,
/// Shape variant
pub shape: AvatarShape,
/// Additional CSS classes
pub class: Option<&'static str>,
/// The avatar image
pub image: Option<Image>,
/// The fallback content (shown when image fails)
pub fallback: Option<Fallback>,
}
impl Avatar<(), ()> {
pub const fn new() -> Self {
Self {
size: AvatarSize::Md,
shape: AvatarShape::Circle,
class: None,
image: None,
fallback: None,
}
}
}
impl<Image, Fallback> Default for Avatar<Image, Fallback> {
fn default() -> Self {
Self {
size: AvatarSize::Md,
shape: AvatarShape::Circle,
class: None,
image: None,
fallback: None,
}
}
}
impl<
B: WriteBuf,
Image: WebRenderSync<L, B>,
Fallback: WebRenderSync<L, B>,
L: ::proa_core::DataLoader,
> WebRenderSync<L, B> for Avatar<Image, Fallback>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = AVATAR_RECIPE.resolve(AvatarRecipeProps {
size: Some(self.size),
shape: Some(self.shape),
});
html_sync! {
<span
data-avatar
data-scope={AVATAR_ANATOMY.scope()}
data-part="root"
data-slot="avatar"
data-size={style.size().as_str()}
data-shape={style.shape().as_str()}
class={proa_macros::text!("{} {}", style, self.class)}
>
{self.image}
{self.fallback}
</span>
}
.render(cx)
}
}
/// AvatarImage - the image element
#[derive(Default)]
pub struct AvatarImage {
/// Image source URL
pub src: &'static str,
/// Alt text for accessibility
pub alt: &'static str,
/// Additional CSS classes
pub class: Option<&'static str>,
}
impl<B: WriteBuf, L: ::proa_core::DataLoader> WebRenderSync<L, B> for AvatarImage {
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<img
data-avatar-image
data-scope={AVATAR_ANATOMY.scope()}
data-part="image"
data-slot="avatar-image"
src={self.src}
alt={self.alt}
class={proa_macros::text!("{} {}", AVATAR_RECIPE.image, self.class)}
/>
}
.render(cx)
}
}
/// AvatarFallback - content shown when image fails to load
pub struct AvatarFallback<C = ()> {
/// Size for text sizing (should match parent Avatar)
pub size: AvatarSize,
/// Additional CSS classes
pub class: Option<&'static str>,
/// The fallback content (typically initials)
pub children: Option<C>,
}
impl<C> Default for AvatarFallback<C> {
fn default() -> Self {
Self {
size: AvatarSize::Md,
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for AvatarFallback<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = AVATAR_RECIPE.resolve_fallback(self.size);
html_sync! {
<span
data-avatar-fallback
data-scope={AVATAR_ANATOMY.scope()}
data-part="fallback"
data-slot="avatar-fallback"
data-size={self.size.as_str()}
class={proa_macros::text!("{} {}", style, self.class)}
>
{self.children}
</span>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::ctx::Ctx;
use proa_core::text::text;
#[test]
fn avatar_renders_with_data_attributes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Avatar {
size: AvatarSize::Lg,
shape: AvatarShape::Circle,
class: None,
image: Some(AvatarImage {
src: "https://example.com/avatar.jpg",
alt: "User avatar",
class: None,
}),
fallback: Some(AvatarFallback {
size: AvatarSize::Lg,
class: None,
children: Some(text("JD")),
}),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-avatar"));
assert!(html.contains("data-slot=\"avatar\""));
assert!(html.contains("data-avatar-image"));
assert!(html.contains("data-slot=\"avatar-image\""));
assert!(html.contains("data-avatar-fallback"));
assert!(html.contains("data-slot=\"avatar-fallback\""));
assert!(html.contains("data-scope=\"avatar\""));
assert!(html.contains("data-part=\"root\""));
assert!(html.contains("data-part=\"image\""));
assert!(html.contains("data-part=\"fallback\""));
assert!(html.contains("src=\"https://example.com/avatar.jpg\""));
assert!(html.contains("alt=\"User avatar\""));
assert!(html.contains("JD"));
}
#[test]
fn avatar_renders_different_sizes() {
for size in [
AvatarSize::Xs,
AvatarSize::Sm,
AvatarSize::Md,
AvatarSize::Lg,
AvatarSize::Xl,
AvatarSize::Xxl,
] {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Avatar::<(), ()> {
size,
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains(&format!("data-size=\"{}\"", size.as_str())));
}
}
#[test]
fn avatar_renders_different_shapes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Avatar::<(), ()> {
shape: AvatarShape::Square,
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("rounded-lg"));
assert!(html.contains("data-shape=\"square\""));
}
#[test]
fn avatar_image_has_correct_classes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AvatarImage {
src: "test.jpg",
alt: "Test",
class: Some("custom-class"),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("aspect-square"));
assert!(html.contains("object-cover"));
assert!(html.contains("custom-class"));
}
#[test]
fn avatar_fallback_has_correct_classes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AvatarFallback {
size: AvatarSize::Xl,
class: None,
children: Some(text("AB")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("bg-muted"));
assert!(html.contains("text-lg"));
assert!(html.contains("AB"));
}
#[test]
fn nested_fallback_carries_root_owned_size_selectors() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Avatar {
size: AvatarSize::Xl,
fallback: Some(AvatarFallback {
size: AvatarSize::Sm,
class: None,
children: Some(text("AB")),
}),
..Avatar::<(), _>::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-size=\"xl\""));
assert!(html.contains("group/avatar"));
assert!(html.contains("group-data-[size=xl]/avatar:text-lg"));
}
}
md.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
use super::{Avatar, AvatarFallback, AvatarImage};
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
use proa_macros::md_sync;
/// Avatar renders its image when present, otherwise its fallback text.
impl<
Loader: ::proa_core::DataLoader,
B: WriteBuf,
Image: MdRenderSync<Loader, B>,
Fallback: MdRenderSync<Loader, B>,
> MdRenderSync<Loader, B> for Avatar<Image, Fallback>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
match self.image {
Some(image) => image.render_md(cx),
None => self.fallback.render_md(cx),
}
}
}
/// AvatarImage renders as a standard Markdown image: ``.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf> MdRenderSync<Loader, B> for AvatarImage {
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
md_sync! { "?;
cx.write_md_url(self.src)?;
md_sync! { ")" }.render_md(cx)
}
}
/// AvatarFallback renders its children inline (typically initials).
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for AvatarFallback<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
let prev = cx.md_set_inline(true);
let result = self.children.render_md(cx);
cx.md_set_inline(prev);
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn avatar_renders_fallback_text() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Avatar::<(), _> {
fallback: Some(AvatarFallback {
children: Some("JD"),
..Default::default()
}),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "JD");
}
#[test]
fn avatar_image_renders_markdown_image() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
AvatarImage {
src: "test.jpg",
alt: "Test",
class: None,
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "");
}
#[test]
fn avatar_prefers_image_over_fallback() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Avatar {
image: Some(AvatarImage {
src: "/avatar.jpg",
alt: "Jane Doe",
class: None,
}),
fallback: Some(AvatarFallback {
children: Some("JD"),
..Default::default()
}),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "");
}
#[test]
fn avatar_image_escapes_alt_and_sanitizes_source() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
AvatarImage {
src: "javascript:alert(1)",
alt: "User [admin]",
class: None,
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "![User \\[admin\\]]()");
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
#![allow(clippy::module_inception)]
//! Avatar component - displays user profile images with fallback support
//!
//! The Avatar component provides a styled circular (or rounded) image container
//! with automatic fallback to initials or placeholder when the image fails to load.
use crate::Anatomy;
mod avatar;
mod md;
mod recipe;
/// Stable DOM parts exposed by Avatar.
pub const AVATAR_ANATOMY: Anatomy = Anatomy::new("avatar", &["root", "image", "fallback"]);
// Re-export avatar types
pub use avatar::{Avatar, AvatarFallback, AvatarImage};
// Re-export recipe types
pub use recipe::{
avatar_classes, avatar_fallback_base_classes, avatar_fallback_classes, avatar_image_classes,
avatar_recipe, AvatarFallbackStyle, AvatarRecipe, AvatarRecipeProps, AvatarShape, AvatarSize,
AvatarStyle, AVATAR_RECIPE,
};
// Re-export metadata export function (only when feature is enabled)
#[cfg(feature = "metadata-export")]
pub use recipe::export_metadata;
#[cfg(test)]
mod anatomy_tests {
use super::*;
#[test]
fn anatomy_declares_every_avatar_part() {
assert_eq!(AVATAR_ANATOMY.scope(), "avatar");
assert_eq!(AVATAR_ANATOMY.parts(), &["root", "image", "fallback"]);
}
}
recipe.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Avatar recipe — typed root axes and named image/fallback slots.
//!
//! A nested fallback follows the root Avatar's `data-size` through a named
//! group. Its legacy local size still provides sensible standalone rendering,
//! while the root remains authoritative when the parts are composed.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
mod base {
use super::*;
pub const ROOT: &str = tw_join!(
"group/avatar",
"relative",
"flex",
"shrink-0",
"overflow-hidden"
);
pub const IMAGE: &str = tw_join!("aspect-square", "h-full", "w-full", "object-cover");
pub const FALLBACK: &str = tw_join!(
"flex",
"h-full",
"w-full",
"items-center",
"justify-center",
"bg-muted",
"text-muted-foreground",
"font-medium",
"group-data-[size=xs]/avatar:text-xs",
"group-data-[size=sm]/avatar:text-xs",
"group-data-[size=md]/avatar:text-sm",
"group-data-[size=lg]/avatar:text-base",
"group-data-[size=xl]/avatar:text-lg",
"group-data-[size=xxl]/avatar:text-2xl"
);
}
/// Size variants for the avatar.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "size")]
pub enum AvatarSize {
Xs,
Sm,
#[default]
Md,
Lg,
Xl,
Xxl,
}
impl AvatarSize {
pub const fn classes(self) -> &'static str {
match self {
Self::Xs => "size-6",
Self::Sm => "size-8",
Self::Md => "size-10",
Self::Lg => "size-12",
Self::Xl => "size-16",
Self::Xxl => "size-24",
}
}
pub const fn text_classes(self) -> &'static str {
match self {
Self::Xs | Self::Sm => "text-xs",
Self::Md => "text-sm",
Self::Lg => "text-base",
Self::Xl => "text-lg",
Self::Xxl => "text-2xl",
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Xs => "xs",
Self::Sm => "sm",
Self::Md => "md",
Self::Lg => "lg",
Self::Xl => "xl",
Self::Xxl => "xxl",
}
}
}
/// Shape variants for the avatar.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "shape")]
pub enum AvatarShape {
#[default]
Circle,
Square,
}
impl AvatarShape {
pub const fn classes(self) -> &'static str {
match self {
Self::Circle => "rounded-full",
Self::Square => "rounded-lg",
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Circle => "circle",
Self::Square => "square",
}
}
}
/// Optional inputs accepted by [`AvatarRecipe::resolve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct AvatarRecipeProps {
pub size: Option<AvatarSize>,
pub shape: Option<AvatarShape>,
}
/// Static named classes and defaults for Avatar.
#[derive(Debug, Clone, Copy)]
pub struct AvatarRecipe {
pub root: &'static str,
pub image: &'static str,
pub fallback: &'static str,
size_default: AvatarSize,
shape_default: AvatarShape,
}
impl AvatarRecipe {
pub const fn resolve(&'static self, props: AvatarRecipeProps) -> AvatarStyle {
let size = match props.size {
Some(size) => size,
None => self.size_default,
};
let shape = match props.shape {
Some(shape) => shape,
None => self.shape_default,
};
AvatarStyle {
recipe: self,
size,
shape,
}
}
/// Resolve a standalone fallback's local text size.
///
/// When nested, the root's named-group selectors have greater specificity
/// and make the root `data-size` authoritative.
pub const fn resolve_fallback(&'static self, size: AvatarSize) -> AvatarFallbackStyle {
AvatarFallbackStyle { recipe: self, size }
}
}
/// Fully resolved, streamable Avatar root style.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct AvatarStyle {
recipe: &'static AvatarRecipe,
size: AvatarSize,
shape: AvatarShape,
}
impl AvatarStyle {
pub const fn size(self) -> AvatarSize {
self.size
}
pub const fn shape(self) -> AvatarShape {
self.shape
}
}
impl AttrValue for AvatarStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.recipe.root.as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.size.classes().as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.shape.classes().as_bytes())
}
}
/// Streamable fallback slot plus its standalone size fallback.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct AvatarFallbackStyle {
recipe: &'static AvatarRecipe,
size: AvatarSize,
}
impl AttrValue for AvatarFallbackStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.recipe.fallback.as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.size.text_classes().as_bytes())
}
}
pub const AVATAR_RECIPE: AvatarRecipe = AvatarRecipe {
root: base::ROOT,
image: base::IMAGE,
fallback: base::FALLBACK,
size_default: AvatarSize::Md,
shape_default: AvatarShape::Circle,
};
pub const fn avatar_recipe() -> &'static AvatarRecipe {
&AVATAR_RECIPE
}
/// Compatibility delegates for the former helpers.
pub const fn avatar_classes(size: AvatarSize, shape: AvatarShape) -> AvatarStyle {
AVATAR_RECIPE.resolve(AvatarRecipeProps {
size: Some(size),
shape: Some(shape),
})
}
pub const fn avatar_image_classes() -> &'static str {
AVATAR_RECIPE.image
}
pub const fn avatar_fallback_base_classes() -> &'static str {
AVATAR_RECIPE.fallback
}
pub const fn avatar_fallback_classes(size: AvatarSize) -> AvatarFallbackStyle {
AVATAR_RECIPE.resolve_fallback(size)
}
impl crate::variant_spec::ComponentSpec for AvatarRecipe {
const NAME: &'static str = "Avatar";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
vec![
crate::variant_spec::variant_metadata::<AvatarSize>(),
crate::variant_spec::variant_metadata::<AvatarShape>(),
]
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::AVATAR_ANATOMY)
}
}
#[cfg(feature = "metadata-export")]
pub fn export_metadata() -> crate::variant_spec::ComponentMetadata {
crate::variant_spec::component_metadata(&AVATAR_RECIPE)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipe_exposes_named_slots() {
let recipe = avatar_recipe();
assert!(recipe.root.contains("group/avatar"));
assert!(recipe.image.contains("object-cover"));
assert!(recipe
.fallback
.contains("group-data-[size=xl]/avatar:text-lg"));
}
#[test]
fn recipe_resolves_defaults_and_overrides() {
let default = AVATAR_RECIPE.resolve(AvatarRecipeProps::default());
assert_eq!(default.size(), AvatarSize::Md);
assert_eq!(default.shape(), AvatarShape::Circle);
let custom = AVATAR_RECIPE.resolve(AvatarRecipeProps {
size: Some(AvatarSize::Xl),
shape: Some(AvatarShape::Square),
});
assert_eq!(custom.size(), AvatarSize::Xl);
assert_eq!(custom.shape(), AvatarShape::Square);
assert!(custom.size().classes().contains("size-16"));
assert!(custom.shape().classes().contains("rounded-lg"));
}
#[test]
fn fallback_supports_standalone_and_root_owned_sizes() {
assert_eq!(AvatarSize::Xxl.text_classes(), "text-2xl");
assert!(AVATAR_RECIPE
.fallback
.contains("group-data-[size=xxl]/avatar:text-2xl"));
let standalone = AVATAR_RECIPE.resolve_fallback(AvatarSize::Xs);
assert_eq!(standalone.size, AvatarSize::Xs);
}
}