sveltelean Primitives
Versionv0.2.0 GitHub

Example

Images, initials, sizes and a group
Amara Okafor Jonas Lindqvist
Amara Okafor
<div class="row">
	<span data-slean="avatar">
		<img src="/demo/avatar-amara.svg" alt="Amara Okafor" width="40" height="40" loading="lazy" />
		<span data-slean-part="fallback" aria-hidden="true">AO</span>
	</span>
	<span data-slean="avatar" role="img" aria-label="Mei Tanaka">
		<span data-slean-part="fallback" aria-hidden="true">MT</span>
	</span>
	<span data-slean="avatar" data-size="sm" role="img" aria-label="Rafael Duarte">
		<span data-slean-part="fallback" aria-hidden="true">RD</span>
	</span>
	<span data-slean="avatar" data-size="lg">
		<img src="/demo/avatar-jonas.svg" alt="Jonas Lindqvist" width="56" height="56" loading="lazy" />
		<span data-slean-part="fallback" aria-hidden="true">JL</span>
	</span>
	<span data-slean="avatar" data-size="lg" data-variant="square" role="img" aria-label="Harbor Studio">
		<span data-slean-part="fallback" aria-hidden="true">HS</span>
	</span>
	<div data-slean="avatar" data-variant="group" role="group" aria-label="Reviewers">
		<span data-slean="avatar">
			<img src="/demo/avatar-amara.svg" alt="Amara Okafor" width="40" height="40" loading="lazy" />
			<span data-slean-part="fallback" aria-hidden="true">AO</span>
		</span>
		<span data-slean="avatar" role="img" aria-label="Selin Aydın">
			<span data-slean-part="fallback" aria-hidden="true">SA</span>
		</span>
		<span data-slean="avatar" role="img" aria-label="Priya Raman">
			<span data-slean-part="fallback" aria-hidden="true">PR</span>
		</span>
		<span data-slean="avatar" role="img" aria-label="3 more reviewers">
			<span data-slean-part="fallback" aria-hidden="true">+3</span>
		</span>
	</div>
</div>
Tier 0: no script. The first and fourth avatars are images; the others are initials named by role="img" and aria-label. The group is a wrapper root with data-variant="group"; toggle RTL to see it overlap toward the other side.

Why this implementation exists

An avatar is an image, and the image element already loads lazily, reserves its box from width and height, and names itself from alt. What component libraries usually add is a loading state: a script that waits for the load event and swaps the initials for the image. Stacking both in one grid cell does the same with no script: until the image paints, its box is transparent and the initials show through; once it paints, it covers them.

The part a stylesheet cannot do is react to a failed image, so the contract says so and leaves it to the application, with the handling shown below.

The browser owns

  • loading and decoding the image, lazily with loading="lazy"
  • the name from alt, or from aria-label on a role="img" root
  • painting the image over the initials once it has loaded

Svelte Lean owns

  • avatar.css: the circle or square, three sizes, the initials under the image, the overlapping group
  • the contract: which element carries the name in each form
  • documentation

Usage

The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.

npm install @svelte-lean/styles
stylesheets
import '@svelte-lean/styles/avatar.css';

With an image, the alt text is the name and the initials are aria-hidden. Without one, the root takes role="img" and aria-label. When the name is printed next to the avatar, use alt="" and no label.

Sizes are data-size="sm", the default and "lg"; data-variant="square" is for teams and organisations. A group is one more avatar root with data-variant="group", role="group" and a label, holding the avatars; a count of the rest is an avatar whose initials are the count.

Anatomy

PartElementdata-slean-partRequiredNotes
root<span data-slean="avatar">–yesdata-size="sm|md|lg", data-variant="square". role="img" and aria-label when there is no image.
image<img alt="…">–noalt is the name, or empty when the name is printed next to the avatar.
fallback<span aria-hidden="true">fallbacknoInitials behind the image; visible until it paints or when there is none.
group<div data-slean="avatar" data-variant="group" role="group">–noA wrapper root with an aria-label; its avatars overlap.

Runtime profile

Tier 0: the avatar has no behavior module, the Vite plugin maps avatar to no module, and the native-only fixture proves the path ships no Svelte Lean JavaScript.

Accessibility contract

  • The name is announced once: from the image’s alt, or from aria-label on a role="img" root, never from the initials, which are aria-hidden.
  • A group is a named role="group"; the count avatar says what it counts (3 more reviewers).
  • An avatar is not focusable. When it links to a profile, wrap it in the link and name the link.

Keyboard

KeyWhenResult
TabanywherePasses the avatar by: it is not focusable unless wrapped in a link or button

Platform features

FeatureBaselineOutside the target
<img> with alt, width, height and loading="lazy"Widely availableNot applicable within the support policy
CSS grid stackingWidely availableNot applicable within the support policy

Without JavaScript

Fully functional: loading, painting and the stacking are the browser's and the stylesheet's.

Server rendering

Static HTML. The initials render on the server and stay visible until the image paints.

Before hydration

Nothing is attached. An image that loaded before hydration stays painted.

Styling

avatar.css draws the circle or rounded square, the three sizes with their initials, stacks the image over the initials in one grid cell, hides the alt text of a failed image, and overlaps the avatars of a group with a ring in the page color. The tokens it reads:

TokenDefault (light)Applies to
--slean-radius-full9999pxborder-radius
--slean-mutedvar(--slean-neutral-3)background
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-text-sm0.875remfont-size
--slean-font-weight-semibold600font-weight
--slean-radius-md0.625remborder-radius
--slean-text-xs0.75remfont-size
--slean-text-lg1.125remfont-size
--slean-bgvar(--slean-neutral-1)box-shadow

Variant attributes: data-variant (group, square); data-size (sm, lg).

Compatibility notes

Everything here is widely available. What a failed image paints differs between browsers: Chrome draws an icon over the initials, which the stylesheet does not hide.

Examples

Missing and failed images

The initials show while an image loads because they sit behind it. An image that fails is different: the browser paints its broken-image rendering in the image's box, over the initials (Chrome draws an icon), and the stylesheet can only hide the alt text. The package ships no error handler; the application decides. Rendering no <img> for an empty URL and dropping it on the error event leaves the initials alone in both cases.

Avatar.svelte
<script lang="ts">
	// The application decides what a missing or failed image shows. Rendering no <img> for an
	// empty URL, and dropping it on error, leaves the initials alone in both cases.
	let { name, initials, src }: { name: string; initials: string; src?: string } = $props();
	let failed = $state(false);
</script>

{#if src && !failed}
	<span data-slean="avatar">
		<img {src} alt={name} width="40" height="40" onerror={() => (failed = true)} />
		<span data-slean-part="fallback" aria-hidden="true">{initials}</span>
	</span>
{:else}
	<span data-slean="avatar" role="img" aria-label={name}>
		<span data-slean-part="fallback" aria-hidden="true">{initials}</span>
	</span>
{/if}

Decorative avatars

When the name is printed next to the avatar, the avatar repeats it. An empty alt keeps it out of the accessibility tree so the name is read once.

author.html
<!-- The name is printed next to the avatar, so the avatar itself stays silent. -->
<p class="author">
	<span data-slean="avatar" data-size="sm">
		<img src="/demo/avatar-amara.svg" alt="" width="24" height="24" />
		<span data-slean-part="fallback" aria-hidden="true">AO</span>
	</span>
	Amara Okafor
</p>

Testing

Source