---
title: Avatar
description: A person at glyph size: the image if it loads, initials on a toned fill if it does not.
source: https://adamjurek.com/components/avatar
---

## Home


Avatar renders a person at glyph size: an image if `src` loads, initials on a
toned fill if it does not or never had one. Use it for a user's face or a
byline. AvatarGroup stacks several with a `+N` overflow count past `max`, for
a list of people rather than one.

<!-- ::start:showcase demo="avatar" height="380" -->
<!-- ::end:showcase -->

## Why it is built this way

The fallback is initials on a toned fill rather than a grey silhouette,
because a grid of identical placeholder heads says "nobody is here" while a
grid of initials says who is. The image failure is tracked in state with
`onError`, not left to CSS, because a broken-image icon inside a circle is
the one result rendering worse than either the photo or the initials.

AvatarGroup overlaps its faces because overlap reads as "together" in a way a
row of separate circles does not, and past `max` the rest collapse into a
count - a row of fourteen avatars is a dataset, not a group.

> [!NOTE] Install commands are not written here
> Anything in `packages/ui/registry.ts` gets its TanStack and shadcn commands
> attached automatically, so there is nothing to keep in sync.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/avatar.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/avatar.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | content · Media |
| Files | `avatar.tsx` |
| Dependencies | None |
| Tags | image, fallback, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { Avatar, AvatarGroup } from "@sushindustries/ui";

export function Example() {
	return (
		<>
			<Avatar name="Ada Lovelace" tone="motion" />
			<AvatarGroup
				people={[
					{ name: "Ada Lovelace" },
					{ name: "Grace Hopper" },
					{ name: "Katherine Johnson" },
				]}
				max={2}
			/>
		</>
	);
}
```

## What you should see

A circle with "AL" centred inside it, on the `motion` tone's fill - no
`src` was given, so the fallback is what's showing, not a broken state.
The group below shows two overlapping circles ("AL", "GH") and a third
circle reading "+1", since `max={2}` was passed against three people.

## If nothing happens

An avatar that never shows an image even with `src` set usually means the
request failed after the first render - check the network tab for a 404
or a CORS error, since a failed `<img>` load is exactly what triggers the
initials fallback. A blank circle with no letters means `name` was an
empty string; initials come only from words `name` actually contains.


## Guides


## Variants

`tone` writes `data-tone`, and it only matters for the initials fallback -
once an image loads, the fill sits behind a photograph nobody sees:

```tsx
<Avatar name="Ada Lovelace" tone="motion" />
```

```css
.avatar[data-tone="motion"] {
	background: var(--tone-motion);
	color: var(--tone-motion-ink);
}
```

The five tones are the same pairs the nav, badges and cards use -
`motion`, `layout`, `content`, `docs`, `3d` - so a person tagged to a
category reads as that category everywhere on the site.

## AvatarGroup counts from the whole list, not the visible part

`max` caps how many faces render, but `people.length` still decides the
overflow count - passing three people with `max={2}` always shows "+1",
never a wrong count from a slice taken too early. There is no dedupe: two
entries with the same `name` render as two separate circles.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `src?` | `string` | - | Image URL. Absent or failed, the initials take over. |
| `name` | `string` | - | The person's name; the alt text and the source of the initials. |
| `size?` | `number` | `32` | Pixel size. |
| `tone?` | `string` | - | Colour family for the initials fill. |

### AvatarGroupProps

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `people` | `readonly Pick<AvatarProps, "name" \| "src" \| "tone">[]` | - | In display order; the first renders on top. |
| `max?` | `number` | `4` | How many faces before the count takes over. |
| `size?` | `number` | `32` | Pixel size of every face, the overflow count included. |

<!-- /generated:api -->

## Notes

`AvatarGroup`'s `people` accepts `name`, `src` and `tone` only - any other
field on a source object (an id, a role) is not read by this component
and should stay in the array the host maps from rather than being passed
through. `max` clamps the visible faces but never removes anyone from the
overflow count; passing `max={0}` renders only the "+N" badge, with every
person folded into it.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="avatar" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

```tsx
import { Avatar, Card } from "@sushindustries/ui";

export function ReviewCard({ author, tone, quote }: { author: string; tone: string; quote: string }) {
	return (
		<Card title={author} meta="Verified">
			<div className="flex items-center gap-3">
				<Avatar name={author} tone={tone} size={40} />
				<p className="m-0 fg-dim text-sm">{quote}</p>
			</div>
		</Card>
	);
}
```

## What this example is not

`ReviewCard` never sets `src` here, so every avatar renders as initials -
the fallback is a real design choice in this example, not a placeholder
standing in for a missing photo.
