Avatar
One span. A photo goes in through src, a framework image goes in as a child, and initials fall out of letters — the stylesheet clips whichever one arrives, at any depth, with no wrapper and no cloning. The presence dot is a pseudo-element on the box itself, so nothing can clip it either, and a group is a flex row with one negative margin.
- Stable
- React 19
- One element
- Zero drawing JS
Code
<Avatar src={user.photo} /><span class="wayra-avatar" ><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%233d6dd8%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23eef2ff%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23eef2ff%22%2F%3E%3C%2Fsvg%3E" alt="" loading="lazy" decoding="async" /></span>That is the whole common case: a 48px circle around a plain <img>, lazy-loaded and
decoded off the main thread. The image has no alternative text, and that is deliberate.
An avatar almost always sits next to the person’s name, and a second copy of the name only
makes the row longer to listen to. Pass alt when the avatar stands alone and nothing else
identifies it:
<Avatar src={user.photo} alt={user.name} />The three ways to fill it
Section titled “The three ways to fill it”They are tried in order — src, then children, then letters — and only one of them
ever draws.
Code
<Avatar src="/daniel.jpg" alt="Daniel Vela" />
<Avatar><Image src={daniel} alt="Daniel Vela" width={96} height={96} /></Avatar>
<Avatar letters="DV" alt="Daniel Vela" /><span class="wayra-avatar" ><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%233d6dd8%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23eef2ff%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23eef2ff%22%2F%3E%3C%2Fsvg%3E" alt="From src" loading="lazy" decoding="async" /></span><span class="wayra-avatar" ><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%230e7a52%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23ecfdf5%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23ecfdf5%22%2F%3E%3C%2Fsvg%3E" alt="From a framework image" loading="lazy" decoding="async" /></span><span class="wayra-avatar" role="img" aria-label="Daniel Vela" data-placeholder="true" >DV</span>src renders a plain <img>. The library takes no framework dependency, so if you want
next/image, unpic, a <picture> with three sources or an inline <svg>, you pass it
as a child instead — it is rendered exactly as written, with nothing around it:
.wayra-avatar :is(img, picture, video, svg) { display: block; inline-size: 100%; block-size: 100%; border-radius: var(--wayra-avatar-radius); object-fit: cover;}That one rule is why there is no wrapper element and no cloneElement. The corner comes
from the variable rather than from inherit, so the <img> inside a <picture> — two
levels down — is clipped exactly like a bare one. object-fit: cover means a rectangular
photo is centred and cropped rather than squashed.
With no src and no child, letters draws the placeholder.
When the image fails
Section titled “When the image fails”A src that fails to load counts as no src: the avatar drops the broken-image icon and
draws the next branch instead. An empty string never reaches an <img> at all, and a new
src is tried again. So pass letters next to a photo that might be missing, and the
initials are the fallback:
<Avatar src={user.photo} letters={user.initials} alt={user.name} />Your own onError still runs when the image fails, before the fallback draws.
Colors
Section titled “Colors”color picks the semantic role behind the placeholder fill and the ring. It reaches the
DOM as data-color, and the stylesheet turns that into the role pair.
Code
<Avatar letters="PR" alt="primary" color="primary" /><Avatar letters="SE" alt="secondary" color="secondary" /><Avatar letters="AC" alt="accent" color="accent" /><Avatar letters="NE" alt="neutral" color="neutral" /><Avatar letters="IN" alt="info" color="info" /><Avatar letters="SU" alt="success" color="success" /><Avatar letters="WA" alt="warning" color="warning" /><Avatar letters="ER" alt="error" color="error" /><span class="wayra-avatar" role="img" aria-label="primary" data-placeholder="true" data-color="primary">PR</span><span class="wayra-avatar" role="img" aria-label="secondary" data-placeholder="true" data-color="secondary">SE</span><span class="wayra-avatar" role="img" aria-label="accent" data-placeholder="true" data-color="accent">AC</span><span class="wayra-avatar" role="img" aria-label="neutral" data-placeholder="true" data-color="neutral">NE</span><span class="wayra-avatar" role="img" aria-label="info" data-placeholder="true" data-color="info">IN</span><span class="wayra-avatar" role="img" aria-label="success" data-placeholder="true" data-color="success">SU</span><span class="wayra-avatar" role="img" aria-label="warning" data-placeholder="true" data-color="warning">WA</span><span class="wayra-avatar" role="img" aria-label="error" data-placeholder="true" data-color="error">ER</span>The fill is --wayra-color-<role> and the letters are its -content counterpart. That is
the whole two-token contract the library is built on, and it is why the initials clear
4.5:1 by construction rather than by luck: a role that failed against its own content token
would never have shipped. A theme moves both halves at once, so a placeholder never needs
retuning.
The ring reads the same pair, which is why an avatar has one color and not a separate
borderColor.
Code
<Avatar src="/daniel.jpg" alt="xs" size="xs" /><Avatar src="/daniel.jpg" alt="sm" size="sm" /><Avatar src="/daniel.jpg" alt="md" size="md" /><Avatar src="/daniel.jpg" alt="lg" size="lg" /><Avatar src="/daniel.jpg" alt="xl" size="xl" /><span class="wayra-avatar" data-size="xs"><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%233d6dd8%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23eef2ff%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23eef2ff%22%2F%3E%3C%2Fsvg%3E" alt="xs" loading="lazy" decoding="async" /></span><span class="wayra-avatar" data-size="sm"><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%233d6dd8%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23eef2ff%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23eef2ff%22%2F%3E%3C%2Fsvg%3E" alt="sm" loading="lazy" decoding="async" /></span><span class="wayra-avatar" data-size="md"><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%233d6dd8%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23eef2ff%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23eef2ff%22%2F%3E%3C%2Fsvg%3E" alt="md" loading="lazy" decoding="async" /></span><span class="wayra-avatar" data-size="lg"><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%233d6dd8%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23eef2ff%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23eef2ff%22%2F%3E%3C%2Fsvg%3E" alt="lg" loading="lazy" decoding="async" /></span><span class="wayra-avatar" data-size="xl"><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%233d6dd8%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23eef2ff%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23eef2ff%22%2F%3E%3C%2Fsvg%3E" alt="xl" loading="lazy" decoding="async" /></span>The ladder walks --wayra-size-selector, the unit the small controls share, on multipliers
of its own. It sits below the radial ring at every step,
because an avatar lives in a chat row or a member list rather than on a dashboard.
| Size | Box | Initials | Status dot |
|---|---|---|---|
xs |
24px | 9.6px | 5.3px |
sm |
32px | 12.8px | 7px |
md |
48px | 19.2px | 10.6px |
lg |
64px | 25.6px | 14.1px |
xl |
80px | 32px | 17.6px |
Everything in the box is a fraction of the box: the initials are size / 2.5 and the dot is
22% of it, so one number scales the whole drawing. Measured with the default
--wayra-size-selector of 0.25rem; a theme that changes that token moves the ladder with
it.
There is no size={40} — no prop in this library takes a CSS length. A one-off measurement
is a custom property on the instance, and because everything derives from it, the result is
still proportional:
<Avatar src={user.photo} style={{ '--wayra-avatar-size': '2.75rem' }} />Shapes
Section titled “Shapes”Code
<Avatar src="/daniel.jpg" alt="Circle" /><Avatar src="/ana.jpg" alt="Square" shape="square" /><Avatar letters="DV" alt="Daniel Vela" shape="square" /><span class="wayra-avatar" ><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%233d6dd8%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23eef2ff%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23eef2ff%22%2F%3E%3C%2Fsvg%3E" alt="Circle" loading="lazy" decoding="async" /></span><span class="wayra-avatar" data-shape="square"><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%230e7a52%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23ecfdf5%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23ecfdf5%22%2F%3E%3C%2Fsvg%3E" alt="Square" loading="lazy" decoding="async" /></span><span class="wayra-avatar" role="img" aria-label="Daniel Vela, square" data-placeholder="true" data-shape="square">DV</span>A circle is the pill radius on a square box, so there is no 50% anywhere in this
stylesheet — one private token owns every round corner in the library. A square is
size / 6, proportional rather than fixed: a single flat radius would round the 24px step
into a capsule and leave the 80px step looking sharp.
Status
Section titled “Status”Code
<Avatar src="/daniel.jpg" alt="Online" status="online" /><Avatar src="/ana.jpg" alt="Offline" status="offline" /><Avatar letters="DV" alt="Daniel Vela" status="online" /><Avatar src="/lu.jpg" alt="Online, square" shape="square" status="online" /><span class="wayra-avatar" data-status="online"><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%233d6dd8%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23eef2ff%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23eef2ff%22%2F%3E%3C%2Fsvg%3E" alt="Online" loading="lazy" decoding="async" /></span><span class="wayra-avatar" data-status="offline"><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%230e7a52%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23ecfdf5%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23ecfdf5%22%2F%3E%3C%2Fsvg%3E" alt="Offline" loading="lazy" decoding="async" /></span><span class="wayra-avatar" role="img" aria-label="Daniel Vela" data-placeholder="true" data-status="online">DV</span><span class="wayra-avatar" data-shape="square" data-status="online"><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%236b31c9%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23f5f3ff%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23f5f3ff%22%2F%3E%3C%2Fsvg%3E" alt="Online, square" loading="lazy" decoding="async" /></span>The dot is a pseudo-element on the avatar itself, and that is the reason this component has
no inner frame: a wrapper that clipped the image would clip the dot with it. It is outlined
in --wayra-avatar-ring, the surface colour, so it stays readable on top of any photo.
On a circle the dot sits inside the box, because a round box hides its own corner. On a square it moves out onto the corner the rounding would have taken, which is the only rule in the file that keys on two attributes at once.
Code
<Avatar src="/daniel.jpg" alt="Active" ring /><Avatar src="/ana.jpg" alt="Active" ring color="success" /><Avatar letters="DV" alt="Daniel Vela" ring color="accent" /><span class="wayra-avatar" data-ring="true"><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%233d6dd8%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23eef2ff%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23eef2ff%22%2F%3E%3C%2Fsvg%3E" alt="Active" loading="lazy" decoding="async" /></span><span class="wayra-avatar" data-ring="true" data-color="success"><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%230e7a52%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23ecfdf5%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23ecfdf5%22%2F%3E%3C%2Fsvg%3E" alt="Active, success" loading="lazy" decoding="async" /></span><span class="wayra-avatar" role="img" aria-label="Daniel Vela" data-placeholder="true" data-ring="true" data-color="accent">DV</span>Two layers of box-shadow: the surface colour first at 2px, then the role at 4px. The gap
is what keeps the ring reading as a ring rather than as a thick border, and because it is a
shadow it costs no layout — a ringed avatar is exactly the same size as a plain one and a
row of them does not shift when one becomes active.
Groups
Section titled “Groups”AvatarGroup overlaps its members and cuts each one out of the one behind it.
Code
<AvatarGroup><Avatar src="/ana.jpg" alt="Ana" /><Avatar src="/beto.jpg" alt="Beto" /><Avatar src="/carla.jpg" alt="Carla" /><Avatar letters="+9" alt="9 more people" /></AvatarGroup><div class="wayra-avatar-group" > <span class="wayra-avatar" ><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%233d6dd8%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23eef2ff%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23eef2ff%22%2F%3E%3C%2Fsvg%3E" alt="Ana" loading="lazy" decoding="async" /></span> <span class="wayra-avatar" ><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%230e7a52%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23ecfdf5%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23ecfdf5%22%2F%3E%3C%2Fsvg%3E" alt="Beto" loading="lazy" decoding="async" /></span> <span class="wayra-avatar" ><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%238a4b0d%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23fffbeb%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23fffbeb%22%2F%3E%3C%2Fsvg%3E" alt="Carla" loading="lazy" decoding="async" /></span> <span class="wayra-avatar" role="img" aria-label="9 more people" data-placeholder="true" >+9</span></div>The overlap is --wayra-avatar-size * -0.25 — a quarter of each member’s own size, so a
row of mixed sizes still overlaps in proportion. The halo is a 3px shadow in the surface
colour, and a member carrying a ring is excluded from it: the ring’s inner layer is already
that separation.
Collapsing with max
Section titled “Collapsing with max”Code
<AvatarGroup max={4}>{team.map((member) => ( <Avatar key={member.id} src={member.photo} alt={member.name} size="sm" />))}</AvatarGroup><div class="wayra-avatar-group" > <span class="wayra-avatar" data-size="sm"><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%233d6dd8%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23eef2ff%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23eef2ff%22%2F%3E%3C%2Fsvg%3E" alt="Ana" loading="lazy" decoding="async" /></span> <span class="wayra-avatar" data-size="sm"><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%230e7a52%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23ecfdf5%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23ecfdf5%22%2F%3E%3C%2Fsvg%3E" alt="Beto" loading="lazy" decoding="async" /></span> <span class="wayra-avatar" data-size="sm"><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%238a4b0d%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23fffbeb%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23fffbeb%22%2F%3E%3C%2Fsvg%3E" alt="Carla" loading="lazy" decoding="async" /></span> <span class="wayra-avatar" data-size="sm"><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22%236b31c9%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2211%22%20fill%3D%22%23f5f3ff%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-13%2011-23%2024-23s24%2010%2024%2023Z%22%20fill%3D%22%23f5f3ff%22%2F%3E%3C%2Fsvg%3E" alt="Dani" loading="lazy" decoding="async" /></span> <span class="wayra-avatar" role="img" aria-label="+2" data-placeholder="true" data-color="neutral" data-size="sm">+2</span></div>Above max, the rest collapse into a counter drawn as an ordinary avatar. It is always
neutral, so it never reads as one more person, and it takes the size of the last member it
follows. Its label is +N — a number rather than a sentence, because it is the one string
this component would otherwise have to translate. If you want words, drop max and write
the last member yourself, as in the example above it.
Members behind a flag cost nothing. null and false children are dropped before anything
is counted, so a guest who is not there cannot push a face into the counter:
<AvatarGroup max={4}> {team.map((member) => <Avatar key={member.id} src={member.photo} alt={member.name} />)} {guest && <Avatar src={guest.photo} alt={guest.name} />}</AvatarGroup>Styling contract
Section titled “Styling contract”The class plus the data-* attributes is the public API, and the stylesheet is written
against it. Every preview on this page is hand-authored HTML — there is no React on it:
<span class="wayra-avatar" data-color="primary" data-size="md" data-shape="circle" data-status="online"> <img src="/daniel.jpg" alt="" loading="lazy" decoding="async" /></span>
<span class="wayra-avatar" role="img" aria-label="Daniel Vela" data-placeholder="true" data-color="primary" data-size="md" data-shape="circle"> DV</span>data-placeholder="true" is what fills the box; without it a span with letters in it is
just a transparent box with centred text. In hand-written markup you also write the
role="img" and the aria-label yourself — the component only emits them on the branch
that generates them.
Three custom properties are yours to set:
| Property | Default | What it does |
|---|---|---|
--wayra-avatar-size |
calc(var(--wayra-size-selector) * 12) |
The box in both axes. The initials and the dot are fractions of it. |
--wayra-avatar-radius |
9999px, or calc(size / 6) when data-shape="square" |
The corner, applied to the box and to every image inside it. |
--wayra-avatar-ring |
var(--wayra-color-base-100) |
The surface the avatar is cut out of: dot outline, ring, and group halo. |
Set --wayra-avatar-ring wherever an avatar sits on something other than the page
background — a card in base-200, a coloured header — and the dot outline, the ring’s inner
layer and the group halo all follow at once.
API reference
Section titled “API reference”AvatarProps extends ComponentProps<"span"> minus color, so id, style, ref,
className, title and every data-* land on the box.
| Prop | Type | Default | Description |
|---|---|---|---|
color |
WayraColor |
"primary" |
Role behind the placeholder fill and the ring. Emitted as data-color. |
size |
WayraSize |
"md" |
Step on the avatar ladder, 24px to 80px. |
shape |
"circle" | "square" |
"circle" |
Round or proportionally rounded corners. |
status |
"online" | "offline" |
— | Draws the presence dot. Never the only signal. |
ring |
boolean |
false |
Draws the two-layer ring in the role colour. |
src |
string |
— | Source for the plain <img> path. Wins over children and letters unless it is empty or fails to load. |
alt |
string |
— | Image alternative text, or the accessible name of a placeholder. |
letters |
string |
— | Initials for the placeholder. Usually one or two. |
children |
ReactNode |
— | A framework image, rendered with no wrapper. |
unstyled |
boolean |
false |
Drops the wayra-avatar class, keeps every data-*. |
AvatarGroupProps extends ComponentProps<"div">.
| Prop | Type | Default | Description |
|---|---|---|---|
max |
number |
— | How many members to show before the rest collapse into +N. |
children |
ReactNode |
— | The avatars. Required. null and false are dropped before counting. |
unstyled |
boolean |
false |
Drops the wayra-avatar-group class from the row. |
Three things the legacy component had are deliberately gone. There is no numeric size, for
the reason under Sizes. There is no border plus borderColor pair: they were two
props where one axis exists, and setting a colour without a width did nothing at all — ring
and color cannot be put into that state. And there is no innerClassName, because there is
no inner element left to name.
Accessibility
Section titled “Accessibility”- An image avatar is decorative by default. No
altmeansalt="", which keeps a redundant reading of a name that is already on screen out of the accessibility tree. It is a real emptyalt, notaria-hiddenon top of one — the empty attribute is what axe and every screen reader agree on. - A standalone avatar needs a name. If the avatar is the only thing identifying the
person — a row of faces with no text — pass
alt. - A placeholder is an image. It carries
role="img"and a computedaria-label, so the initials are announced as the person, not spelled out as two letters. The label isaria-label, thenalt, then the letters themselves; the last of those is legal but poor, so pass one of the first two. With none of the three there is nothing to name, and the box drops the role and stays out of the tree, as an image with an emptyaltdoes. - Naming precedence is yours to override.
aria-labelon the component wins overalt, which is how one avatar in a list carries a longer name than the rest. - The status dot is silent. It is a pseudo-element: it has no text and no role, and colour alone never carries meaning. Put the state in the row as text.
- Contrast is the role contract. The initials are the role’s
-contenttoken on its solid, measured at 4.5:1 or better in both schemes in every shipped theme butwayra, the documented exception — the one palette that trades a measured ratio for reproducing its source untouched. The dot outline is the surface, so the dot is separated from the photo by shape rather than by hue. - Windows high contrast. The placeholder keeps an outline where its fill flattens, and the dot opts out to hold a system colour. Both states become the same mark there — one more reason the state has to be written somewhere too.
- Loading. Images ship
loading="lazy"anddecoding="async". A face above the fold that must load eagerly goes in throughchildren, where you control the tag.
Do and don’t
Section titled “Do and don’t”Do
The name is on the page, so the image is decorative: alt="". A screen reader reads the row once.
Don't
The same name twice in a row — once from the image, once from the text beside it.
Do
The dot is the fast signal; the word is the one that survives high contrast, greyscale and a screen reader.
Don't
Presence carried by a green dot and nothing else. Half the audience never receives it.