Avatar
One person or organisation as a fixed-size circle or rounded square: a photo, falling back to initials on a stable colour, then a generic icon. Five sizes, an optional presence dot and a decorative mode for use beside a visible name.
cmp_avatar_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_avatar_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Neutral
- Version
- 1.0.0
- Digest
Full digest
sha256-263051643254c3c6ae67acd585ffb1a31e4de9356baa27c006b8a0a33578a6be
<script lang="ts" module>
export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export type AvatarShape = 'circle' | 'square';
export type AvatarStatus = 'online' | 'away' | 'offline';
export type AvatarFallback = 'initials' | 'icon';
/*
* Initials and the fallback colour are pure functions of the name, so the server and the
* browser agree and the same person keeps the same colour on every page. Both are exported
* for an avatar group or a member list that needs the same answer.
*/
const LETTER = /[\p{L}\p{N}]/u;
/* Scripts whose names are one character per syllable: one character is the convention. */
const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
/* Scripts whose letters join; a zero-width non-joiner keeps two initials as two letters. */
const JOINING = /[\p{Script=Arabic}\p{Script=Syriac}\p{Script=Nko}\p{Script=Mongolian}]/u;
/*
* Grapheme clusters keep a combining mark or a conjunct with its letter. Segmentation is the
* same in every language, so it runs with the root locale rather than the host's default.
*/
const segmenter =
typeof Intl.Segmenter === 'function'
? new Intl.Segmenter('und', { granularity: 'grapheme' })
: undefined;
const graphemes = (text: string): string[] =>
segmenter ? Array.from(segmenter.segment(text), (s) => s.segment) : Array.from(text);
const firstLetter = (word: string) => graphemes(word).find((g) => LETTER.test(g)) ?? '';
/*
* Without a locale, uppercase the same way on every machine so SSR and hydration match. An
* empty or malformed language tag falls back to that rather than throwing.
*/
const upper = (text: string, locale?: string) => {
if (!locale) return text.toUpperCase();
try {
return text.toLocaleUpperCase(locale);
} catch {
return text.toUpperCase();
}
};
/** First letters of the first and last words; one letter for one word or a CJK name. */
export function avatarInitials(name: string, locale?: string): string {
const words = name
.normalize('NFC')
.trim()
.split(/\s+/)
.filter((word) => LETTER.test(word));
if (words.length === 0) return '';
const first = firstLetter(words[0]);
if (words.length === 1 || CJK.test(first)) return upper(first, locale);
const last = firstLetter(words[words.length - 1]);
const joiner = JOINING.test(first) && JOINING.test(last) ? '' : '';
return upper(first + joiner + last, locale);
}
/** A stable index into a palette of `count` colours: FNV-1a over the normalised name. */
export function avatarToneIndex(name: string, count: number): number {
let hash = 0x811c9dc5;
for (const unit of name.normalize('NFC').trim()) {
hash ^= unit.codePointAt(0) ?? 0;
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0) % count;
}
</script>
<script lang="ts">
interface Props {
/** The person's or organisation's name: the accessible name and the source of the initials. */
name: string;
/** Image URL. Omitted, or failing to load, the initials show instead. */
src?: string;
/** Fixed size step: 24, 32, 40, 48 or 64 px. */
size?: AvatarSize;
/** Circle for people, rounded square for organisations. */
shape?: AvatarShape;
/** Set when the name is visible beside the avatar, so it is not read twice. */
decorative?: boolean;
/** Presence dot, with its word available to screen readers. */
status?: AvatarStatus;
/** The status word read by screen readers, for other languages. Defaults to Online, Away or Offline. */
statusLabel?: string;
/** Override the derived initials, for a name the rule gets wrong. */
initials?: string;
/** What shows without an image: initials, or a generic person or building icon. */
fallback?: AvatarFallback;
/** Language of the name; uppercases initials for it (Turkish i becomes İ) and sets lang. */
lang?: string;
/** Extra classes on the root, for margins. */
class?: string;
}
let {
name,
src,
size = 'md',
shape = 'circle',
decorative = false,
status,
statusLabel,
initials,
fallback = 'initials',
lang,
class: className
}: Props = $props();
/*
* One frame for all three fillings. Each step sets the box, the initials size, the square's
* corner, the status dot and the width of the cut-out around it; the initials sit at about
* 0.4 of the box. Complete class names, never interpolated.
*/
const SIZES: Record<AvatarSize, { root: string; px: number }> = {
xs: {
root: '[--_box:1.5rem] [--_corner:0.375rem] [--_dot:0.5rem] [--_cut:2px] text-[0.625rem]',
px: 24
},
sm: {
root: '[--_box:2rem] [--_corner:0.375rem] [--_dot:0.625rem] [--_cut:2px] text-[0.8125rem]',
px: 32
},
md: {
root: '[--_box:2.5rem] [--_corner:0.5rem] [--_dot:0.75rem] [--_cut:2px] text-base',
px: 40
},
lg: {
root: '[--_box:3rem] [--_corner:0.5rem] [--_dot:0.875rem] [--_cut:2px] text-lg',
px: 48
},
xl: {
root: '[--_box:4rem] [--_corner:0.75rem] [--_dot:1rem] [--_cut:3px] text-2xl',
px: 64
}
};
/* --_r is the corner the status dot sits on: half the box for a circle, the corner for a square. */
const SHAPES: Record<AvatarShape, string> = {
circle: 'avatar--circle',
square: 'avatar--square'
};
/*
* Initials fills, picked by a hash of the name. Each is one --avatar-tone-N fill and its
* --avatar-tone-N-ink; every default pair clears 7:1, and none is so saturated that a list
* of members reads as confetti. Complete class names, never interpolated.
*/
const TONES = [
'bg-(--_tone-1) text-(--_tone-1-ink)',
'bg-(--_tone-2) text-(--_tone-2-ink)',
'bg-(--_tone-3) text-(--_tone-3-ink)',
'bg-(--_tone-4) text-(--_tone-4-ink)',
'bg-(--_tone-5) text-(--_tone-5-ink)',
'bg-(--_tone-6) text-(--_tone-6-ink)',
'bg-(--_tone-7) text-(--_tone-7-ink)',
'bg-(--_tone-8) text-(--_tone-8-ink)'
];
const STATUS: Record<AvatarStatus, { dot: string; label: string }> = {
online: { dot: 'bg-(--_online)', label: 'Online' },
away: { dot: 'avatar__status--away', label: 'Away' },
offline: { dot: 'avatar__status--offline bg-(--_surface)', label: 'Offline' }
};
const step = $derived(SIZES[size] ?? SIZES.md);
const letters = $derived((initials ?? avatarInitials(name, lang)).trim());
const cjk = $derived(CJK.test(letters));
const tone = $derived(TONES[avatarToneIndex(name, TONES.length)]);
/* Reset whenever src changes, so a new or restored URL gets its own attempt. */
let failed = $derived.by(() => {
void src;
return false;
});
const showImage = $derived(Boolean(src) && !failed);
const showInitials = $derived(!showImage && fallback === 'initials' && letters !== '');
const presence = $derived(status ? STATUS[status] : undefined);
/* A blank name has nothing to announce, so the frame is hidden as if decorative. */
const hidden = $derived(decorative || name.trim() === '');
</script>
<span
class={[
'avatar relative inline-flex size-(--_box) shrink-0 align-middle',
SHAPES[shape] ?? SHAPES.circle,
step.root,
className
]}
>
<span
class={[
'avatar__frame relative grid size-full place-items-center overflow-hidden',
shape === 'square' ? 'rounded-(--_corner)' : 'rounded-full',
showInitials ? tone : 'bg-(--_fill) text-(--_muted)'
]}
role={hidden ? undefined : 'img'}
aria-label={hidden ? undefined : name}
aria-hidden={hidden ? 'true' : undefined}
lang={lang || undefined}
>
{#if showImage}
<!-- The frame carries the name, so the image itself is unlabelled: a broken image then
draws nothing before the initials replace it. -->
<img
class="size-full object-cover"
{src}
alt=""
width={step.px}
height={step.px}
decoding="async"
onerror={() => (failed = true)}
/>
{:else if showInitials}
<span
class="avatar__initials leading-none font-medium tracking-normal whitespace-nowrap select-none"
data-cjk={cjk ? '' : undefined}>{letters}</span
>
{:else if shape === 'square'}
<!-- A building for an organisation without a mark. -->
<svg class="size-[62.5%]" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path
d="M6 3.5A1.5 1.5 0 0 1 7.5 2h9A1.5 1.5 0 0 1 18 3.5V20h1.25a.75.75 0 0 1 0 1.5H4.75a.75.75 0 0 1 0-1.5H6V3.5Zm4 14.25V20h4v-2.25a.75.75 0 0 0-.75-.75h-2.5a.75.75 0 0 0-.75.75ZM9 5.5v1.5h2V5.5H9Zm4 0v1.5h2V5.5h-2Zm-4 3.5v1.5h2V9H9Zm4 0v1.5h2V9h-2Zm-4 3.5V14h2v-1.5H9Zm4 0V14h2v-1.5h-2Z"
/>
</svg>
{:else}
<!-- A head and shoulders, cropped by the frame like a portrait. -->
<svg class="mt-[18%] size-full" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="12" cy="8.5" r="4.25" />
<path d="M2.5 24c.6-5.6 4.6-9.25 9.5-9.25s8.9 3.65 9.5 9.25H2.5Z" />
</svg>
{/if}
</span>
{#if presence}
<span class={['avatar__status absolute size-(--_dot) rounded-full', presence.dot]}>
<span class="sr-only">{statusLabel ?? presence.label}</span>
</span>
{/if}
</span>
<style>
/*
* Public tokens: set --avatar-* on the avatar or any ancestor to retone it. --avatar-surface
* is the page colour behind the avatar, used for the cut-out around the status dot.
*/
.avatar {
--_fill: var(--avatar-fill, #f4f4f5);
--_muted: var(--avatar-muted, #71717a);
--_hairline: var(--avatar-hairline, rgb(0 0 0 / 0.08));
--_surface: var(--avatar-surface, #ffffff);
--_online: var(--avatar-online, #059669);
--_away: var(--avatar-away, #d97706);
--_offline: var(--avatar-offline, #71717a);
/* Initials fills and their ink: sky, emerald, amber, rose, violet, lime, orange, indigo. */
--_tone-1: var(--avatar-tone-1, #dff2fe);
--_tone-1-ink: var(--avatar-tone-1-ink, #024a70);
--_tone-2: var(--avatar-tone-2, #d0fae5);
--_tone-2-ink: var(--avatar-tone-2-ink, #004f3b);
--_tone-3: var(--avatar-tone-3, #fef3c6);
--_tone-3-ink: var(--avatar-tone-3-ink, #7b3306);
--_tone-4: var(--avatar-tone-4, #ffe4e6);
--_tone-4-ink: var(--avatar-tone-4-ink, #8b0836);
--_tone-5: var(--avatar-tone-5, #ede9fe);
--_tone-5-ink: var(--avatar-tone-5-ink, #4d179a);
--_tone-6: var(--avatar-tone-6, #ecfcca);
--_tone-6-ink: var(--avatar-tone-6-ink, #35530e);
--_tone-7: var(--avatar-tone-7, #ffedd4);
--_tone-7-ink: var(--avatar-tone-7-ink, #7e2a0c);
--_tone-8: var(--avatar-tone-8, #e0e7ff);
--_tone-8-ink: var(--avatar-tone-8-ink, #312c85);
}
/* The corner the dot sits on, for the dot's position formula below. */
.avatar--circle {
--_r: calc(var(--_box) / 2);
}
.avatar--square {
--_r: var(--_corner);
}
/*
* Hairline over every filling, so a pale photo or a light tint keeps its edge on a white
* page. Drawn above the image as a pseudo-element because an inset shadow on the frame
* would sit beneath it.
*/
.avatar__frame::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 0 1px var(--_hairline);
pointer-events: none;
}
/*
* The dot's centre sits on the frame's edge at 45 degrees: r(1 − cos 45°) in from the
* corner of the box. The page-coloured ring cuts it out of the photo beneath.
*/
.avatar__status {
inset-inline-end: calc(var(--_r) * 0.2929 - var(--_dot) / 2);
inset-block-end: calc(var(--_r) * 0.2929 - var(--_dot) / 2);
box-shadow: 0 0 0 var(--_cut) var(--_surface);
}
/*
* Each state has its own shape as well as its colour: online is a full dot, away a crescent
* (the dot with a page-coloured bite out of its top start) and offline a ring.
*/
.avatar__status--away {
background: radial-gradient(
circle at 12% 12%,
var(--_surface) 0 44%,
var(--_away) calc(44% + 0.5px)
);
}
.avatar__status--away:dir(rtl) {
background: radial-gradient(
circle at 88% 12%,
var(--_surface) 0 44%,
var(--_away) calc(44% + 0.5px)
);
}
.avatar__status--offline {
box-shadow:
inset 0 0 0 calc(var(--_dot) * 0.2) var(--_offline),
0 0 0 var(--_cut) var(--_surface);
}
/*
* Latin capitals centre on their cap height rather than the line box, which otherwise sits
* them a little low. CJK characters fill the em square and are already centred.
*/
@supports (text-box: trim-both cap alphabetic) {
.avatar__initials:not([data-cjk]) {
text-box: trim-both cap alphabetic;
}
}
</style>
Usage#
On this pagePass the name and, when there is one, an image URL; pick a size and shape, and set decorative when the name is printed beside it. It renders one inline frame. It does not upload, crop or fetch images (no Gravatar), does not track presence (you pass status), does not link or open a menu (wrap it in your own link or button), and does not stack several people (that is an avatar group).
- Suggested location
src/lib/components/avatar-01- Required props
name
Limitations
- Initials come from the first and last words, so a name written family-name-first in a Latin script ("Okafor Daniel") gives OD. Pass initials for names the rule gets wrong.
- The status dot's cut-out is painted in
--avatar-surface, white by default. On a tinted or dark page set it to the page colour, or the ring shows as a white halo. - The fill colour is a hash of the name, not of an ID, so renaming someone changes their colour and two people with the same name share one.
- Light mode only. On a dark page set
--avatar-fill,--avatar-muted,--avatar-hairline,--avatar-surfaceand the eight--avatar-tone-Npairs. - Until the browser has hydrated the component, an image that fails shows the frame's plain fill rather than the initials.
Example
<script lang="ts">
import Avatar from '$lib/components/avatar-01/Avatar.svelte';
</script>
<Avatar name="Hanna Lindqvist" src="/people/hanna.jpg" status="online" />
<p class="flex items-center gap-3">
<Avatar name="Daniel Okafor" size="sm" decorative />
Daniel Okafor
</p>
<Avatar name="Fernhill Bakery" shape="square" size="lg" />The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Avatar#
One person or organisation in a fixed frame. The frame tries the image first, then initials, then a generic icon, and all three share the same box, hairline and status dot, so a list that mixes them reads as one set.
Beside a visible name#
Most avatars sit next to the name they stand for: a comment author, a member row, an account
menu. Set decorative there. The frame is then hidden from screen readers, and only the name
you print is read. A status is still announced, because the printed name does not say it.
<li class="flex items-center gap-3">
<Avatar name={member.name} src={member.photo} status={member.presence} decorative />
<span>{member.name}</span>
</li>On its own (a header account button with no text), leave decorative off; the frame is then a
role="img" named by name, and the button around it can use that as its label.
Initials#
- Two words or more: the first letters of the first and last words (
Ada Lovelace→AL). - One word: one letter (
Plato→P). - Chinese, Japanese and Korean names: the first character (
山田 太郎→山). - Arabic and other joining scripts keep two initials as separate letters.
- Leading symbols are skipped (
@studio sage→SS). A name with no letters shows the icon.
Pass initials when the rule is wrong for a name, and lang when the name is in a language
with its own case rules.
The fill colour is a hash of the name, so the server and the browser pick the same one and a
person keeps it across pages. avatarInitials and avatarToneIndex are exported from the
module script for an avatar group or member list that needs the same answer.
Images#
Give the image at least twice the largest size you render (128 px for xl). It is cropped to
the frame with object-cover; centre the face in the source image. If the URL fails, the
initials replace it without changing the avatar's size.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | Yes | None | The person's or organisation's name: the accessible name, and the source of the initials and the fill colour. A blank name hides the avatar from assistive technology, since there is nothing to announce. |
src | string | No | None | Image URL. Omitted, or when it fails to load, the initials show instead. |
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | No | 'md' | Fixed size step: 24, 32, 40, 48 or 64 px. |
shape | 'circle' | 'square' | No | 'circle' | Circle for people, rounded square for organisations. The square's corner grows with the size. |
decorative | boolean | No | false | Set when the name is visible beside the avatar: the frame is hidden from assistive technology so the name is read once. A status word is still read. |
status | 'online' | 'away' | 'offline' | No | None | Presence dot on the frame's edge, with its word for screen readers. Online is a full dot, away a crescent and offline a ring, so the state does not rest on colour. |
statusLabel | string | No | None | The status word read by screen readers, for other languages. Defaults to Online, Away or Offline. |
initials | string | No | None | Override the derived initials, for a name the first-and-last-word rule gets wrong. |
fallback | 'initials' | 'icon' | No | 'initials' | What shows without an image: initials, or a generic person icon (a building on a square avatar). The icon also shows when the name has no letters. |
lang | string | No | None | Language of the name. Uppercases the initials by its rules (Turkish i becomes İ) and is set as lang on the frame, so the name is pronounced in it while the status word is not. |
class | string | No | None | Extra classes on the root, for margins. |
Customization#
On this pageChoose size, shape and status through props. Retone the fallback, hairline, cut-out, presence colours and the eight initials fills with --avatar-* variables.
- Initials fills: eight pairs,
--avatar-tone-1to--avatar-tone-8for the fill and--avatar-tone-N-inkfor its letters (sky, emerald, amber, rose, violet, lime, orange, indigo by default). Set them to retone the fallback; keep each pair at 4.5:1 or better. To use fewer colours, shorten the TONES list in the source; the hash spreads names across however many there are. - Hairline:
--avatar-hairlineis the one-pixel edge drawn over every filling (the issue's ring), black at 8% by default. Set it to transparent to drop it, or a stronger value on a busy background. - Tinted or dark page: set
--avatar-surfaceto the page colour so the cut-out around the status dot matches it. For a dark page also set--avatar-filltozinc-800,--avatar-mutedtozinc-400,--avatar-hairlineto rgb(255 255 255 / 0.1), and each--avatar-tone-Nand -ink to a dark fill with light letters. - Presence colours:
--avatar-online,--avatar-awayand--avatar-offline. Keep each at 3:1 against the page. - Sizes: the SIZES map in the source sets each step's box, initials size, square corner, dot and cut-out as CSS variables. Add a 2xl step by copying xl with --_box:5rem.
- Linking: wrap the avatar in your own <a> or <button> with a focus ring; the avatar itself is not interactive. When the link has visible text, set decorative.
- Shape for organisations: shape="square" gives a 6 to 12 px corner by size; change --_corner in SIZES for a softer or squarer mark.
Public CSS variables
| Variable | Token |
|---|---|
--avatar-fill | fill |
--avatar-muted | muted |
--avatar-hairline | hairline |
--avatar-surface | surface |
--avatar-online | online |
--avatar-away | away |
--avatar-offline | offline |
--avatar-tone-1 | tone1 |
--avatar-tone-1-ink | tone1Ink |
--avatar-tone-2 | tone2 |
--avatar-tone-2-ink | tone2Ink |
--avatar-tone-3 | tone3 |
--avatar-tone-3-ink | tone3Ink |
--avatar-tone-4 | tone4 |
--avatar-tone-4-ink | tone4Ink |
--avatar-tone-5 | tone5 |
--avatar-tone-5-ink | tone5Ink |
--avatar-tone-6 | tone6 |
--avatar-tone-6-ink | tone6Ink |
--avatar-tone-7 | tone7 |
--avatar-tone-7-ink | tone7Ink |
--avatar-tone-8 | tone8 |
--avatar-tone-8-ink | tone8Ink |
Accessibility#
On this page- The frame is one role="img" named by the name prop, whatever fills it, so a photo, initials and the icon all read the same way; the img inside has an empty alt so a broken image draws nothing.
- Decorative mode hides the frame from assistive technology. Use it whenever the name is printed beside the avatar, so screen readers do not read it twice.
- A status dot carries its word (Online, Away, Offline, or
statusLabel) as visually hidden text, read even in decorative mode because the visible name does not say it. Online, away and offline differ in shape (dot, crescent, ring) as well as colour. - Every initials fill clears 7:1 with its letters, the generic icon clears 4.4:1 against its fill, and the presence colours clear 3:1 against white.
- The avatar is not focusable. If it links to a profile, the link and its focus ring are yours; give the link an accessible name.
- Pass lang for a name in another language so it is pronounced, and its initials uppercased, by that language's rules.
Known limitations
- Contrast is checked for the default colours on a white page; retoned or dark pages are the consumer's to check.
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 21 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.