AvatarATM
An image element with a fallback for representing the user. Supports sizes, status badges, and avatar groups.
On this page (16)
§With Badge
Presence status at the bottom-right: data-variant='online' (green), 'offline' (grey ring), 'busy' (red with a bar - in a meeting, do not disturb) or 'away' (amber with clock hands). Each state has its own shape as well as its colour, so it reads without colour vision too. role='img' + aria-label gives the dot its spoken name.
§Status in a list
The badge is the person's state at a glance - pair it with the state in text wherever there is room, so the dot is a shortcut, not the only carrier.
§Badge position
data-position on the .avatar-badge puts it on any of the four corners: top-start, top-end, bottom-start or bottom-end (the default). start / end follow the reading direction - in RTL, start is the right.
§Shapes
data-shape="rounded" or "square" reshapes the avatar - image, fallback and ring follow. For silhouettes, put a shape-* class from theme/utils/shapes.css on the .avatar-image (here: shape-heart, shape-squircle, shape-hexagon-2, shape-decagon, shape-star-2): only the photo is cut, so a badge still sits on top.
§Custom sizes
Beyond the xs–xl scale, the sizing utilities set any size: w-32 h-32 is 8rem. The badge keeps its size; set data-size for a matching one.
§Ring
data-ring draws a ring with a background-colored gap - --primary by default, data-ring="secondary" or "destructive" for the others. It follows data-shape.
§Placeholder
Without an image the .avatar-fallback letters are the avatar. data-variant="primary" or "neutral" puts them on a solid plate; the letters scale with data-size.
§Group overlap and counter
data-overlap="lg" stacks the avatars tighter ("sm" looser); the .avatar-group-count closes the row with how many are left.
§States
Named states via the shared State API, driven per instance through the bound api:
default- image shown (or fallback when there is no image)error- the broken-image look, forced without a network failure (same DOM changes a realerrorevent makes)
The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/avatar-{state}.png.
Machine contract - verified against avatar.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
error | boolean | true, false | false | Image load failure → initials fallback (the runtime marks .avatar-image with data-error). |
§API
Generated from avatar.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type AvatarState = 'default' | 'error' - setState(name, config) takes the config of the state it names.
| State | Description |
|---|---|
default | The image shows (or only the fallback is authored). No config. |
error | The image failed to load - the fallback (initials, an icon) shows instead. No config. |
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
el.api.setState<S extends AvatarState>(name: S, config?: AvatarStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | |||||||||
el.api.getState(): { name: AvatarState; config: AvatarStateConfigs[AvatarState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed. Returns | |||||||||
el.api.render(state?: { name: AvatarState; config: AvatarStateConfigs[AvatarState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
el.store: Store<{ name: AvatarState; config: AvatarStateConfigs[AvatarState] }> | A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component. |
Registry
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
df$.shadcn.avatarApi.setState<S extends AvatarState>(el: HTMLElement, name: S, config?: AvatarStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.avatarApi.getState(el: HTMLElement): { name: AvatarState; config: AvatarStateConfigs[AvatarState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.avatarApi.render(state: { name: AvatarState; config: AvatarStateConfigs[AvatarState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | ||||||||||||
df$.shadcn.avatarApi.store(el: HTMLElement): Store<{ name: AvatarState; config: AvatarStateConfigs[AvatarState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.avatarApi.commit<S extends AvatarState>(el: HTMLElement, name: S, config?: AvatarStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.avatarStates: AvatarState[] | The declared states, 'default' first: default, error. |
§CSS view file
Hide fallback when image is loaded
@layer components { .avatar { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; border-radius: 9999px; flex-shrink: 0; background-color: var(--muted); /* No overflow:hidden: the badge dot sits half outside the circle (like shadcn's -right-1 dot) and clipping cut it to a sliver. Circular rendering comes from border-radius: inherit on image/fallback instead. */ &[data-size="xs"] { width: 1.5rem; height: 1.5rem; --_badge: 0.75rem; font-size: 0.5625rem; } &[data-size="sm"] { width: 2rem; height: 2rem; --_badge: 1rem; font-size: 0.6875rem; } &[data-size="md"] { width: 2.5rem; height: 2.5rem; --_badge: 1.25rem; font-size: 0.75rem; } &[data-size="lg"] { width: 3rem; height: 3rem; --_badge: 1.5rem; font-size: 0.875rem; } &[data-size="xl"] { width: 4rem; height: 4rem; --_badge: 1.75rem; font-size: 1rem; } /* -- Shape: the image and fallback inherit the radius, so one attribute reshapes the whole avatar (the badge is never clipped). For silhouettes (heart, squircle, hexagon…) put a shape-* class from theme/utils/shapes.css on the .avatar-image instead. */ &[data-shape="rounded"] { border-radius: var(--radius-lg); } &[data-shape="square"] { border-radius: var(--radius-sm); } /* a loaded photo covers the box: no muted plate behind a shaped image */ &:has(> .avatar-image:not([data-error])) { background-color: transparent; } /* -- Ring: a colored ring with a background-colored gap, following the avatar's shape */ &[data-ring] { box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--primary); } &[data-ring="secondary"] { box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--muted-foreground); } &[data-ring="destructive"] { box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--destructive); } } .avatar-image { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; } .avatar-fallback { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; font-size: 0.75rem; font-weight: 500; color: var(--muted-foreground); background-color: var(--muted); position: absolute; inset: 0; border-radius: inherit; /* .avatar no longer clips - the fallback must round itself */ /* placeholder colors (letters on a solid plate) */ &[data-variant="primary"] { background-color: var(--primary); color: var(--primary-foreground); } &[data-variant="neutral"] { background-color: var(--foreground); color: var(--background); } } /* Hide fallback when image is loaded */ .avatar:has(.avatar-image:not([data-error])) .avatar-fallback { display: none; } /* Status dot. data-variant picks the presence state; no variant = online (the historical green). Status colours are literals by design - the tweakcn token set has no success/warning pair (AGENTS.md token rule). Colour is never the only cue (WCAG 1.4.1): offline is a hollow ring, busy carries a bar, away clock hands - the four read apart in greyscale and in forced-colors mode. Every cue is PAINTED inside a full circle (no mask): the silhouette and its separating rim stay whole, so the dot always sits in front of the avatar. Size scales with the avatar (--_badge, set per data-size below); rim and cues scale with it. Name it with role="img" + aria-label. */ .avatar-badge { --_rim: max(2px, calc(var(--_badge, 1.25rem) * 0.15)); position: absolute; inset-inline-end: calc(var(--_rim) * -0.5); inset-block-end: calc(var(--_rim) * -0.5); z-index: 1; width: var(--_badge, 1.25rem); height: var(--_badge, 1.25rem); box-sizing: border-box; border-radius: 9999px; background-color: #16a34a; border: var(--_rim) solid var(--background); background-repeat: no-repeat; &[data-variant="online"] { background-color: #16a34a; } /* data-position: any of the four corners (default bottom-end); start / end are logical - in RTL, start is the right */ &[data-position^="top"] { inset-block: calc(var(--_rim) * -0.5) auto; } &[data-position$="start"] { inset-inline: calc(var(--_rim) * -0.5) auto; } /* hollow ring: "not here" */ &[data-variant="offline"] { background-color: var(--background); box-shadow: inset 0 0 0 max(2px, calc(var(--_badge, 1.25rem) * 0.14)) #9ca3af; } /* red with a bar: do not disturb */ &[data-variant="busy"] { background-color: #dc2626; background-image: linear-gradient(#fff 0 0); background-size: 56% max(2px, calc(var(--_badge, 1.25rem) * 0.14)); background-position: center; } /* amber with clock hands (12 → 3 o'clock): away / be right back */ &[data-variant="away"] { background-color: #f59e0b; background-image: linear-gradient(#fff 0 0), linear-gradient(#fff 0 0); background-size: max(2px, calc(var(--_badge, 1.25rem) * 0.12)) 34%, 30% max(2px, calc(var(--_badge, 1.25rem) * 0.12)); /* hour hand: centred, from ~16% down to the centre; minute hand: from the centre to ~80% (percent positions: (box - layer) * p) */ background-position: 50% 24%, 71% 50%; } } /* Avatar group - data-overlap="sm|lg" tightens / loosens the stack */ .avatar-group { display: flex; align-items: center; & .avatar { border: 2px solid var(--background); margin-left: -0.5rem; &:first-child { margin-left: 0; } } &[data-overlap="sm"] :is(.avatar, .avatar-group-count):not(:first-child) { margin-left: -0.25rem; } &[data-overlap="lg"] :is(.avatar, .avatar-group-count):not(:first-child) { margin-left: -1rem; } } .avatar-group-count { display: inline-flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; border-radius: 9999px; background-color: var(--muted); color: var(--muted-foreground); font-size: 0.75rem; font-weight: 500; border: 2px solid var(--background); margin-left: -0.5rem; }}/* Forced colors (Windows High Contrast) would repaint the status fills with the system Canvas colour and erase the dot. Presence colour carries meaning (like a chart series), so the badge keeps its own palette; the ring / bar / crescent shape cues stay as well. */@media (forced-colors: active) { @layer components { .avatar-badge { forced-color-adjust: none; } }}§JavaScript view file
Interaction logic for the avatar component. Uses data attributes for wiring.
// -- Avatar ---------------------------------------------------// Hides broken avatar images and shows the fallback, plus the named-state// API bound per .avatar wrapper, so agents/tests can show the fallback// without a network failure (AGENTS.md "State API").// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';const df$ = defussGlobals();const dfDollar = defussQuery();const avatarStates = ['default', 'error'];// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each// described, and every config field typed, described and named in the code./** setState() configs per state - the avatar's states take none. */export interface AvatarStateConfigs { /** The image shows (or only the fallback is authored). */ default: {}; /** The image failed to load - the fallback (initials, an icon) shows instead. */ error: {};}/** * The markup of a state, for render(): the attributes every state writes - * the same as triggerStateChange does on the live element - applied to a * detached copy of the authored markup. The e2e render round trip proves * the two agree. */function applyMarkup(el, stateName) { const img = dfDollar(el).find('.avatar-image'); if (stateName === 'error') img.attr('data-error', '').css('display', 'none'); else img.attr('data-error', null).css('display', '');}/** * UI side of setState (per wrapper): 'error' forces the broken-image look * (same DOM changes the error event makes); 'default' clears it, restoring * the image view. Wrappers without an <img> have nothing to toggle. */function triggerStateChange(wrapper, stateName, _config) { const img = dfDollar(wrapper).find<HTMLImageElement>('.avatar-image').get(0); if (!img) return; switch (stateName) { case 'default': img.removeAttribute('data-error'); img.style.display = ''; break; case 'error': img.setAttribute('data-error', ''); img.style.display = 'none'; break; }}/** Registry-level API; pass the wrapper explicitly. Unknown names throw. */export const avatarApi = componentState({ component: 'avatar', states: avatarStates, apply: (wrapper, state) => triggerStateChange(wrapper, state.name, state.config), read: (wrapper, state) => { // reflect reality: a network failure flips it without setState() const img = dfDollar(wrapper).find<HTMLImageElement>('.avatar-image').get(0); const errored = img ? img.hasAttribute('data-error') : true; return { name: errored ? 'error' : 'default', config: state.config, }; }, markup: (el, state) => applyMarkup(el, state.name),});df$.avatarApi = avatarApi;df$.avatarStates = avatarStates;function init() { dfDollar('.avatar:not([data-init])').toArray().forEach((wrapper) => { wrapper.dataset.init = ''; // el.store + el.api (AGENTS.md "State through stores") bindComponent(wrapper, avatarApi); const img = dfDollar(wrapper).find<HTMLImageElement>('.avatar-image').get(0); if (!img) return; img.dataset.init = ''; // catch images that errored BEFORE this script ran (module scripts are // deferred; a fast/local failure can beat init - image.ts does the same) if (img.complete && img.naturalWidth === 0) applyError(); img.addEventListener('error', applyError); function applyError() { img.setAttribute('data-error', ''); img.style.display = 'none'; // network failure also moves the named state (keeps getState honest) wrapper.dataset.stateName = 'error'; }});}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub