AvatarATM
An image element with a fallback for representing the user. Supports sizes, status badges, and avatar groups.
On this page (15)
§Basic
Avatar with image and fallback.
§Sizes
The full five-step scale via data-size - the default equals md.
§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.
§Avatar Group
Overlapping avatars with an optional count.
§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.
§In a Card
Avatar composed with .card, .badge, and .btn for a user profile pattern.
§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). |
§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 } from '../../shared/state-api.js';const df$ = defussGlobals();const avatarStates = ['default', 'error'];/** * 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 = wrapper.querySelector('.avatar-image'); 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 = { setState(wrapper, stateName, config = {}) { if (!avatarStates.includes(stateName)) { throw new Error(`avatar: unknown state "${stateName}" (supported: ${avatarStates.join(', ')})`); } triggerStateChange(wrapper, stateName, config); // state lives on the ELEMENT, not the module (many avatars per page) wrapper.dataset.stateName = stateName; wrapper._stateConfig = config; }, getState(wrapper) { // reflect reality: a network failure flips it without setState() const img = wrapper.querySelector('.avatar-image'); const errored = img ? img.hasAttribute('data-error') : true; return { name: errored ? 'error' : 'default', config: wrapper._stateConfig ?? {}, }; },};df$.avatarApi = avatarApi;df$.avatarStates = avatarStates;function init() { document.querySelectorAll('.avatar:not([data-init])').forEach((wrapper) => { wrapper.dataset.init = ''; // bind-scope the api per avatar: `$('#my-avatar').api.setState('error')` wrapper.api = { setState: (stateName, config) => avatarApi.setState(wrapper, stateName, config), getState: () => avatarApi.getState(wrapper), }; const img = wrapper.querySelector('.avatar-image'); 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