Theme
On this page (15)
Component Skill — components/avatar/component-skill.md

Native basis

<img> element wrapped in a container <span> with a text fallback for when the image fails to load.

Web Platform APIs

<img>:has() selector

Classes

.avatar.avatar-image.avatar-fallback.avatar-badge.avatar-group.avatar-group-count

Sizes (data-size)

xsSize: 1.5remsmSize: 2remmdSize: 2.5rem (default)lgSize: 3remxlSize: 4rem

Badge status (data-variant on .avatar-badge)

onlineGreen dot (also the no-variant default)offlineHollow grey ringbusyRed dot with a bar - in a meetingawayAmber with clock hands - idle / afk

Shape, ring, placement

data-shape="rounded"Rounded square (--radius-lg) - image, fallback and ring followdata-shape="square"Square with a small radius (--radius-sm)shape-* on .avatar-imageAny silhouette from theme/utils/shapes.css - heart, squircle, hexagon…data-ringRing in --primary with a background gap (secondary / destructive values)data-position on .avatar-badgetop-start / top-end / bottom-start / bottom-end (default)data-variant on .avatar-fallbackprimary / neutral - letters on a solid platedata-overlap on .avatar-groupsm / lg - a tighter or looser stack

Data attributes

• data-error

Accessibility

• <img> must have an alt attribute describing the user

• Fallback text should be initials or a meaningful abbreviation

• A status badge needs role="img" + aria-label ("Online", "Busy" …) - a bare <span> can't carry a name

§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 real error event 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:

StateTypeValuesDefaultDescription
errorbooleantrue, falsefalseImage 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