Avatar
A round image for a person or entity that falls back to initials while loading or on error.
Preview
IAIAIAIAIATFTFTFTFTF
Reviewers · 4
- IADesign lead
Ines Alvarez
Reviewing
- MOPlatform
Malik Okoye
In a call until 15:30
- PRResearch
Priya Raman
Back in 20 min
- TFSupport
Tomás Ferreira
Last seen yesterday
IAMOPRTF+12
16 editing
IAMOPR
Install
Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.
npx shadcn@latest add https://pebbleui.com/r/avatar.jsonProps
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Avatar and AvatarGroupCount: 20, 24, 32, 40 or 48 px. Fallback text scales with it. |
| status | "online" | "away" | "busy" | "offline" | "online" | AvatarStatus: presence dot on the avatar's bottom-right edge. |
| label | string | - | AvatarStatus: accessible name; defaults to the status in words ("Online"). |
| AvatarStatus | React.ComponentProps<"span"> | - | Place inside Avatar; role=img with a page-coloured cutout ring. |
| AvatarGroup | React.ComponentProps<"div"> | - | Overlapping row of Avatars; each lifts on hover. |
| AvatarGroupCount | React.ComponentProps<"span"> | - | The "+N" chip that ends an AvatarGroup; takes the same size prop. |
| src | string | - | AvatarImage: image URL; shown only after it loads. |
| alt | string | "" | AvatarImage: empty (decorative) by default; pass a name when the avatar stands alone. |
| onLoadingStatusChange | (status: "idle" | "loading" | "loaded" | "error") => void | - | AvatarImage: reports the image loading status. |
| delayMs | number | - | AvatarFallback: wait before showing the fallback, to avoid a flash on fast loads. |
| className | string | - | Wins over the defaults on every part. |
Build notes
Built on the Radix Avatar primitive: `Avatar.Image` probes the `src` with an off-DOM `new Image()` and reports `idle → loading → loaded | error`, and `Avatar.Fallback` renders only while the status is not `loaded`, so there is no broken-image icon and no layout jump. The root is a `size-*` flex box with `overflow-hidden rounded-full`; the image fills it with `object-cover` and `aspect-square`. Size lives in a `sizes` map applied to the root, which also sets the font size so the fallback's initials inherit it (instead of the source's group-data selectors). `data-size` is still set on the root for styling hooks. The demo draws an inline `data:` SVG portrait so no network image is fetched. Differences from the source: `md` instead of `default` for the middle size, `alt` defaults to `""`, `object-cover` on the image, `font-medium` initials, and AvatarBadge / AvatarGroup / AvatarGroupCount are not shipped. **Pebble (2026-11-03).** The root no longer clips (`overflow-hidden` dropped); image and fallback take `rounded-[inherit]` so a status dot can sit on the edge. A `pointer-events-none` `avatar-ring` span drawn after the children puts an inset `ring-hairline` over the picture, so pale photos keep an edge. The fallback is a muted pebble with `inset-shadow-highlight` and `-0.01em` tracking. The loaded image fades in with `transition-opacity duration-(--motion-base) ease-enter starting:opacity-0` (no keyframes needed). New `xs` and `xl` sizes. New parts written from scratch: `AvatarStatus` (30 % dot, `outline-2 outline-background` cut-out, named for screen readers), `AvatarGroup` (`-space-x-2`, page-coloured outlines via `*:data-[slot=avatar]:`, motion-safe hover lift) and `AvatarGroupCount` (a raised `shadow-raised-xs` chip with tabular numbers). The demo shows every size with image and initials, a presence list with all four statuses and two groups. <!-- PEBBLE-LICENSE -->
View source
registry/data/avatar/avatar.tsx
"use client";
/* PEBBLE-LICENSE
Built by Pebble. Component: avatar.
First-party license: MIT
The following first-party terms apply only to original Pebble material.
MIT License
Copyright (c) 2026 Praxys Solutions LLC (Pebble)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
THIRD-PARTY NOTICES
These notices apply to identified upstream portions; Pebble terms do not replace or narrow their rights.
Upstream: https://raw.githubusercontent.com/shadcn-ui/ui/main/LICENSE.md
MIT License
Copyright (c) 2023 shadcn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */
import * as React from "react";
import { Avatar as AvatarPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
const sizes = {
xs: "size-5 text-[0.5625rem]",
sm: "size-6 text-[0.625rem]",
md: "size-8 text-xs",
lg: "size-10 text-sm",
xl: "size-12 text-base",
} as const;
export type AvatarProps = React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: keyof typeof sizes;
};
export function Avatar({ size = "md", className, children, ...props }: AvatarProps) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
// No overflow clip on the root, so a status dot can sit on the edge.
// Image and fallback inherit the radius instead.
"relative inline-flex shrink-0 rounded-full select-none",
sizes[size],
className,
)}
{...props}
>
{children}
{/* A hairline drawn over the picture keeps light photos from bleeding
into a light page: the Pebble ring-as-border. */}
<span
aria-hidden
data-slot="avatar-ring"
className="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-hairline ring-inset"
/>
</AvatarPrimitive.Root>
);
}
export function AvatarImage({
className,
alt = "",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
alt={alt}
className={cn(
"aspect-square size-full rounded-[inherit] object-cover",
// Radix mounts the image once it has loaded; it fades in over the
// fallback instead of popping. Opacity only, so reduced motion keeps it.
"transition-opacity duration-(--motion-base) ease-enter starting:opacity-0",
className,
)}
{...props}
/>
);
}
export function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
// Text size comes from the root's size, so initials scale with it. A
// muted pebble with a top highlight, so initials read as an object.
"flex size-full items-center justify-center rounded-[inherit] bg-muted font-medium tracking-[-0.01em] text-muted-foreground inset-shadow-highlight",
className,
)}
{...props}
/>
);
}
// Forced colors drop author backgrounds and box-shadow rings, so each status
// gets its own system colour there: solid `CanvasText`, `GrayText`,
// `Highlight`, and a hollow `CanvasText` border for offline.
const statuses = {
online: { cls: "bg-success forced-colors:bg-[CanvasText]", label: "Online" },
away: { cls: "bg-warning forced-colors:bg-[GrayText]", label: "Away" },
busy: { cls: "bg-danger forced-colors:bg-[Highlight]", label: "Busy" },
offline: {
cls: "bg-card ring-[1.5px] ring-border-control ring-inset forced-colors:border-[1.5px] forced-colors:border-[CanvasText] forced-colors:bg-[Canvas]",
label: "Offline",
},
} as const;
export type AvatarStatusProps = React.ComponentProps<"span"> & {
status?: keyof typeof statuses;
/** Accessible name; defaults to the status in words ("Online"). */
label?: string;
};
/** A presence dot on the avatar's bottom-right edge, cut out of the page. */
export function AvatarStatus({ status = "online", label, className, ...props }: AvatarStatusProps) {
const s = statuses[status];
return (
<span
data-slot="avatar-status"
data-status={status}
role="img"
aria-label={label ?? s.label}
className={cn(
"absolute right-0 bottom-0 z-10 size-[30%] min-h-1.5 min-w-1.5 rounded-full",
// The outline matches the page so the dot looks cut out of the avatar.
"outline-2 outline-background",
// Forced colors keep the cut-out in the page colour.
"forced-colors:outline-[Canvas]",
s.cls,
className,
)}
{...props}
/>
);
}
/**
* Overlapping row of avatars. Each one is outlined in the page colour so the
* overlap reads as a stack, and lifts a step on hover to be picked out.
*/
export function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"flex items-center -space-x-2",
"*:data-[slot=avatar]:outline-2 *:data-[slot=avatar]:outline-background",
"*:data-[slot=avatar]:motion-safe:transition-transform *:data-[slot=avatar]:motion-safe:duration-(--motion-fast) *:data-[slot=avatar]:motion-safe:ease-enter",
"*:data-[slot=avatar]:hover:z-10 *:data-[slot=avatar]:hover:-translate-y-0.5",
className,
)}
{...props}
/>
);
}
/** The "+N" chip that ends an AvatarGroup. */
export function AvatarGroupCount({
size = "md",
className,
...props
}: React.ComponentProps<"span"> & { size?: keyof typeof sizes }) {
return (
<span
data-slot="avatar-group-count"
className={cn(
"relative inline-flex shrink-0 items-center justify-center rounded-full bg-card font-medium text-muted-foreground tabular-nums",
"shadow-raised-xs ring-1 ring-hairline outline-2 outline-background",
sizes[size],
className,
)}
{...props}
/>
);
}
