Avatar UI Design: 9 Parts, No Image Files
Avatar UI design is how one small circle makes a person recognizable. The nine parts draw it from a name alone — color, fallback, pattern, stack, presence —
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Auto-colored initial avatar
- 02 Three-step fallback avatar
- 03 Name-pattern avatar
- 04 Shape and size set
- 05 Overlapping stack with +N
- 06 Presence dot
- 07 Mention chip input
- 08 Hover user card
- 09 Photo positioning
The order follows the path an avatar takes as it fills itself in. The first three complete the avatar itself: the circle that picks its color from the name (01), the fallback chain for a photo that never arrives (02), and the identicon that tells people apart when initials collide (03). The next three seat it in a system and a team: the shape and size set (04), the overlapping stack with its +N slot (05), and the presence dot (06). The last three are where the avatar enters input and inspection: the mention chip (07), the hover user card (08), and the photo positioning step (09). All nine draw from one palette — ink #17141a, cream #fff7e6, and six tones (blue #2a5fd8, violet #5b4bd6, teal #0d6e5c, plum #a1266f, brick, slate) — so two or three of them can share a screen without the colors fighting. For the card the avatar sits inside, the neighbors are nine profile card design styles and login and signup card UI patterns; the account screen belongs to account security settings UI.
01Auto-colored initial avatar
The name folds into a number that picks one of six colors, so the same name lands in the same circle every time, and typing in the field swaps the circle and its letter on the spot. Nothing is stored — the hash is a pure function of the string. Use it in lists and comments where most people never upload a photo.
// Fold the name into a number 0-5 — a pure function, so the same name always picks the same color
function hashName(text) {
var h = 0;
for (var i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) >>> 0;
return h % 6;
}
// Korean names take the first syllable, Latin names the first letters of the first two words
function initialsOf(text) {
var trimmed = text.trim();
if (!trimmed) return '';
if (/[가-힣]/.test(trimmed[0])) return trimmed[0];
var words = trimmed.split(/\s+/).filter(function (w) { return /[A-Za-z]/.test(w[0] || ''); });
return words.slice(0, 2).map(function (w) { return w[0].toUpperCase(); }).join('');
}
// JS never learns a hex — it flips the data-tone number and scss paints one of six colors
function applyName() {
avatar.dataset.tone = String(hashName(input.value));
avatar.textContent = initialsOf(input.value);
}
.ai__avatar[data-tone="0"] { background: $av-blue; }
.ai__avatar[data-tone="1"] { background: $av-violet; }
.ai__avatar[data-tone="2"] { background: $av-teal; }
.ai__avatar[data-tone="3"] { background: $av-plum; }
.ai__avatar[data-tone="4"] { background: $av-brick; }
.ai__avatar[data-tone="5"] { background: $av-slate; }
02Three-step fallback avatar
A slot waits for the photo, initials take over when the photo never arrives, and a person mark appears when even the name is missing — three steps that cut over in a single frame. The photo element deliberately points at a missing file so the error event drives the chain downward. Use it on screens where externally supplied photo URLs break often.
// The photo deliberately points at a missing file — the error event pushes the fallback step up
img.addEventListener('error', function () {
if (root.dataset.step === 'photo') root.dataset.step = 'initials';
});
if (img.complete && img.naturalWidth === 0 && root.dataset.step === 'photo') {
root.dataset.step = 'initials';
}
// One button walks between initials and the person mark (Enter and Space press it too)
btn.addEventListener('click', function () {
root.dataset.step = (root.dataset.step === 'mark') ? 'initials' : 'mark';
});
// The three steps cut over in a single frame with steps(1, end) — no half-transparent state overlaps.
// 0% waiting / 34% initials / 67% person mark, back to waiting inside 88%.
// Cuts alone would starve the frame count, so the waiting step keeps a shimmer bar sweeping through
.fb.is-demo .fb__photo,
.fb.is-demo .fb__stepTxt--photo {
animation-name: fbCutPhoto;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes fbCutPhoto {
0% { opacity: 1; }
34% { opacity: 0; }
88% { opacity: 1; }
100% { opacity: 1; }
}
03Name-pattern avatar
Hash bits spread across the cells so the same name always yields the same 5x5 pattern, with the left three columns mirrored onto the right so the mark is symmetrical. Each of the 25 cells breathes on its own staggered delay, never switching off through opacity. Use it in team and repository lists where initials collide.
// FNV-1a 32-bit — the same name always returns the same integer
function hash32(text) {
var h = 2166136261;
for (var i = 0; i < text.length; i++) {
h = (h ^ text.charCodeAt(i)) >>> 0;
h = Math.imul(h, 16777619) >>> 0;
}
return h >>> 0;
}
// Fifteen bits land in columns 0, 1, 2; columns 0-4 and 1-3 mirror each other to fill 25 cells
function cellsOf(text) {
var h = hash32(text);
var cells = [];
for (var row = 0; row < 5; row++) {
var left = [(h >>> (row * 3)) & 1, (h >>> (row * 3 + 1)) & 1, (h >>> (row * 3 + 2)) & 1];
cells.push(left[0], left[1], left[2], left[1], left[0]);
}
return cells;
}
.id__grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
}
// Each cell breathes on its own delay — a lit cell never goes out through opacity
.id.is-demo .id__cell {
animation-name: idBreathe;
animation-duration: $duration; // 1s — a divisor of 2s
animation-delay: calc(var(--i) * 40ms);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
04Shape and size set
One avatar moves between circle, squircle, and hexagon while four sizes — 28, 36, 48, and 64px — stand in a row. The letter size and the ring width are ratios of the single --size variable, so every combination keeps its proportions. Use it in a design system that reuses one avatar across screens.
// Every shape is a clip-path — different functions never interpolate, so swaps jump as steps cuts
.as[data-shape="circle"] .as__av { clip-path: circle(50%); }
.as[data-shape="squircle"] .as__av { clip-path: inset(0 round 34%); }
.as[data-shape="hex"] .as__av { clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%); }
.as__av { aspect-ratio: 1; }
// One --size drives the letter size and ring width by ratio (no hardcoded px)
.as__av {
width: var(--size);
font-size: calc(var(--size) * .4);
border: calc(var(--size) / 14) solid $av-deep;
}
05Overlapping stack with +N
Only four people stay visible and the rest fold into a single +N slot; a pointer or focus unfolds the stack into one slot per person, and pressing +N opens the folded roster below. The overlap itself is a negative margin drawn from the spacing tokens. Use it where one row has to say who is on a document or a task.
// The overlap is a negative margin — spacing comes from the 4px grid tokens only
.st__stack > * + * { margin-left: -#{$sp-3}; }
// Unfolding pays the overlap back with translateX — a round trip, never a teleport
@keyframes stSpread {
0% { transform: translateX(0); }
40% { transform: translateX(calc(var(--i) * #{$sp-3})); }
78% { transform: translateX(calc(var(--i) * #{$sp-3})); }
90% { transform: translateX(0); }
100% { transform: translateX(0); }
}
// Folded roster rows — display:flex beats the UA's hidden, so one more line presses it down
.st__row[hidden] { display: none; }
06Presence dot
A mask punches a round hole at the avatar's edge and the dot sits inside that hole, so it never covers the face or slides past the rim. Every state change swaps the adjacent text and the announced name together. Use it on team screens that show who is online right now.
// The point of this demo — a mask punches a hole in the avatar face and the dot sits in it.
// The dot never covers the face or the rim; it lands exactly in the punched opening
.pd__av {
-webkit-mask-image: radial-gradient(circle 11px at 82% 82%, transparent 98%, #000 100%);
mask-image: radial-gradient(circle 11px at 82% 82%, transparent 98%, #000 100%);
}
// The status dot — a 10px circle seated inside the mask hole (82% 82%, about 21px across)
.pd__dot {
position: absolute;
left: 82%;
top: 82%;
width: 10px;
height: 10px;
border-radius: 50%;
transform: translate(-50%, -50%);
}
// The state word lives in the HTML, not in the code — it is read back from the page
function currentLabel() {
var el = root.querySelector('.pd__state span[data-key="' + root.getAttribute('data-state') + '"]');
return el ? el.textContent : '';
}
// The aria-label is built as one finished sentence up front, so no stray gap appears mid-phrase
function applyLabel() {
wrap.setAttribute('aria-label', nameEl.textContent + ', ' + currentLabel());
}
07Mention chip input
Typing the at sign opens a list with avatars, the arrow keys walk it, and the chosen person lands as a chip in front of the field with a remove button to take it back out. In the loop a sweep bar travels 72px down the four rows and returns. Use it in comment and task fields that call people out by name.
<input class="mc__input" type="text" role="combobox" aria-expanded="true"
aria-controls="mc-list" aria-autocomplete="list" aria-activedescendant="mc-opt-0"
placeholder="Write a comment" autocomplete="off">
<ul class="mc__list" id="mc-list" role="listbox" aria-label="People to mention">
<li class="mc__opt" id="mc-opt-0" role="option" aria-selected="true">
<span class="mc__av mc__av--blue" aria-hidden="true"></span><span class="mc__name">Editor</span>
</li>
</ul>
function setActive(i) {
var v = visible();
if (!v.length) return;
active = (i + v.length) % v.length;
v.forEach(function (o, k) { o.setAttribute('aria-selected', k === active ? 'true' : 'false'); });
input.setAttribute('aria-activedescendant', v[active].id);
}
08Hover user card
The card rises only after the pointer has rested on the avatar for 300ms, and it waits 200ms before closing, so it survives the handoff while the pointer travels onto it. The entrance is a single-frame cut at 26% followed by a short pop, with the activity bar filling from 28 to 84%. Use it where only a name shows and a reader needs to check who it is.
// Opening waits 300ms — a grazing pass never shakes the card loose.
// Closing holds a 200ms grace — the card stays alive while the pointer crosses onto it
trigger.addEventListener('pointerenter', function () {
clearTimeout(openT); clearTimeout(closeT);
openT = setTimeout(function () { card.hidden = false; }, 300);
});
function scheduleClose() {
clearTimeout(openT); clearTimeout(closeT);
closeT = setTimeout(function () { card.hidden = true; }, 200);
}
trigger.addEventListener('pointerleave', scheduleClose);
card.addEventListener('pointerenter', function () { clearTimeout(closeT); });
card.addEventListener('pointerleave', scheduleClose);
// The card entrance is a cut plus a short pop — no translucent ghost frame appears
@keyframes ucCard {
0% { opacity: 0; transform: translateY(6px); animation-timing-function: steps(1, end); }
26% { opacity: 1; transform: translateY(6px); animation-timing-function: $easing; }
34%, 100% { opacity: 1; transform: translateY(0); }
}
09Photo positioning
The picture drags under a round window and a slider raises the zoom, while the allowed drag range narrows first so lowering the zoom never exposes a gap at the window edge. The range is maxOffset = (w × 1.5 × z − w) / 2, recomputed against the live window size. Use it as the crop step right after a profile photo upload.
// The core — the allowed drag range is maxOffset = (photoSize * zoom - windowSize) / 2.
// Lower the zoom and the range narrows first, so a gap never opens at the window edge.
// The photo sheet is 1.5x the window (inset -25%)
function clamp(v, zz) {
var w = win.offsetWidth; // window size (unaffected by the render transform)
var m = Math.max(0, (w * 1.5 * zz - w) / 2);
return Math.max(-m, Math.min(m, v));
}
function apply() {
root.style.setProperty('--x', x + 'px');
root.style.setProperty('--y', y + 'px');
root.style.setProperty('--z', '' + z);
}
// The fake photo carries one bright face blob and one dark shoulder blob —
// a lone gradient moved 10px and still failed the motion threshold (measured 13.8)
%cu-photo-bg {
background:
radial-gradient(circle at 32% 26%, $subject-cream 0 17%, rgba($subject-cream, 0) 38%),
radial-gradient(circle at 72% 76%, $stage-ink 0 15%, rgba($stage-ink, 0) 36%),
linear-gradient(160deg, $subject-sky 0%, $subject-lilac 34%, $av-violet 62%, $av-plum 100%);
}
// The transform gathers in one place — drag, zoom, and the preview share these variables
.cu__photo {
position: absolute;
inset: -25%;
transform: translate(var(--x, 0px), var(--y, 0px)) scale(var(--z, 1));
}
.cu__window { clip-path: circle(50%); }
Where it breaks — the trap
The first break the overflow gate caught was the mention list in 07. The absolutely positioned list carried top: 100%, but the height that percentage measured belonged to the padded root that reserves the space, not to the input row, so the list opened below the reserved area and the stage read 300px of viewport against 332px of content. Layout excludes an absolutely positioned box from its parent's height, so a scrollbar on the grid was the only symptom. Moving the anchor to the row — position: relative on the row itself — and leaving the root's padding-bottom equal to the open height put the list inside the reserved space, and the overflow went away.
The second break was the fake photo in 09. A picture painted as one smooth gradient moved by about 10px, and the motion measurement returned an intensity of 13.8, below the threshold of 15: neighboring pixels inside a gradient differ so little that a move the eye follows leaves almost nothing behind in the pixel diff. Laying one bright face blob and one dark shoulder blob over the gradient gave the picture real edges to carry, and the intensity rose to 16.4. The same arithmetic is why caption contrast over a gradient background has to be computed against the colors actually underneath — skip that step, and dark text over a dark stop quietly disappears.
The third was the mask prefix. The mask-image property 06 leans on measured 92.86% global support plus 4.17% partial at caniuse on 2026-09-12, with the unprefixed standard shipping since Chrome 120, Safari 15.4, and Firefox 53; while older versions linger, the -webkit-mask-image line stays next to it.
Finally, the state swaps in 02 and 06 were first built as fades, and the halfway frame showed both states translucent at once — a ghost frame printed straight into the poster. Switching to steps(1, end) cuts killed the ghosts, and because cuts alone starve the frame count, a shimmer bar and a dot pulse stayed on to keep the frames up.
The render numbers retell the same story from the other side. 07 moves its whole list for a cumulative changed area of 21.97%, the widest of the nine; 08 lands the hardest change at an intensity of 125.5; 09 stays the quietest at 16.4 even after the blobs, which is how calmly this part sits on a gradient. At the small end, 06 is one dot pulsing over 0.56% of the frame, yet it moves in 17 of the 24 captured frames — what keeps a small part from reading as a dead picture is frame count, not area. 03 staggers its 25 cells into 100 registered animations with movement across 23 of 24 frames, 01 fills the same 23, and 02 and 04 lean on the shimmer and the pop for their 11 and 14. The finished nine, every break above fixed, are packed in the zip at the end of this page, and the password that opens it is dchsanq2.
Accessibility
Contrast here is not a matter of taste: every pair below was run through the WCAG 2.1 relative luminance formula, and only the passing pairs shipped. The initial letter is always the same cream, on all six tones.
| Foreground | Background | Measured contrast | Body threshold |
|---|---|---|---|
| cream #fff7e6 | blue #2a5fd8 | 5.29:1 | pass |
| cream #fff7e6 | violet #5b4bd6 | 5.76:1 | pass |
| cream #fff7e6 | teal #0d6e5c | 5.79:1 | pass |
| cream #fff7e6 | plum #a1266f | 6.52:1 | pass |
| cream #fff7e6 | brick #c2361a | 5.13:1 | pass |
| cream #fff7e6 | slate #3b4a63 | 8.40:1 | pass |
| cream #fff7e6 | ink #17141a | 17.11:1 | pass |
| ink #17141a | yellow #ffd23f | 12.63:1 | pass |
| ink #17141a | orange #ff4d1f | 5.50:1 | pass |
| ink #17141a | paper #ffffff | 18.24:1 | pass |
| cream #fff7e6 | orange #ff4d1f | 3.11:1 | fail — never used |
| cream #fff7e6 | yellow #ffd23f | 1.35:1 | fail — never used |
Under prefers-reduced-motion: reduce, all nine switch their animations off and keep every name, state, and value on screen. 01 stands with all six circles up, 02 rests on the initials step, and 03 draws the full pattern with the breathing stopped. 04 holds the last chosen shape across the four sizes, 05 stays stacked, and 06 keeps the status color and its text alive. 07 leaves the list open with the sweep bar parked on the first row, 08 rests with the card open and its activity bar full, and 09 parks the photo centered so the window never goes empty.
On the structural side, the initials come from job nouns only — Admin, Editor, Designer — never real names, and the initial letter is computed in JavaScript from the displayed string, so a language pack change carries it along. 01, 03, and 09 put a live input under the hand, a name field or a draggable window; every avatar in 05 is a button, so Tab walks the people one by one; 06 swaps a finished name-and-status sentence into aria-label on each change. 07 reads the highlighted row out loud through role="combobox", aria-expanded, and aria-activedescendant, and 08 ties the card to its trigger with aria-describedby while the open delay and the close grace belong to the pointer alone. The authority for the mask prefix and the pointer handoff is the mask-image documentation on MDN and the pointerenter reference; the contrast bar comes from the W3C's minimum contrast explanation. For more parts like these, the dashboard category collects them, and the about page explains how this site renders and measures every demo.
FAQ
What size should an avatar be?
A set beats a single number. Fix four steps — 28, 36, 48, and 64px as in 04 — and bind the letter size and the ring width to calc(var(--size) * .4) and calc(var(--size) / 14); every size you pick keeps its ratio. On a narrow screen, prefer showing fewer rows over shrinking the circle.
How do you show users who never uploaded a photo?
When the name exists, the auto-colored initial from 01 is the default. On a screen that fetches photo URLs, predefine the three steps as in 02 — waiting shimmer, initials, person mark — and hook the next step to the error event on the img, so a broken address never sits on the page as an empty pill.
Can the presence dot simply overlap the avatar?
Overlaid, it covers the face or slips past the rim. Punch a hole in the avatar face with a mask as in 06 and seat the dot inside it, and the dot lands in one place every time; swap the adjacent text and the aria-label with each state so the color never carries the information alone.