Open the member list in almost any app. Most people never upload a photo, so you see a column of identical grey silhouettes.

when not to use it

The usual fixes each come with a catch:

  • Initials work until two people are both "JS". And white letters on bright tiles often fail contrast.
  • Random colors change on every render, unless you add a database column to store them.
  • Generated avatars are the right idea, but many use Math.random() or hard-coded SVG IDs. Then the server draws one face, the browser draws another, and your console fills with hydration warnings.

I wanted avatars I would never have to think about again. Same input, same bytes, on every server, in every browser and in every release. That became Opaline. This post covers what it gives you, how to use it, and the engineering that makes the promise hold.

Why you might want it

You want What Opaline does
Users you can tell apart Each seed gets its own face, colors and shape. Five styles.
Avatars that never change Same seed and options give a byte-identical SVG for all of 1.x, checked in CI
No hydration warnings Server and client produce the same markup. Tested in Next, Nuxt, SvelteKit and Angular SSR.
One library across your stack One core and components for React 18+, Vue 3.5+, Svelte 5.20+, Angular 20–22, plus a Web Component
A small bundle Core is 1.27 kB gzipped with zero dependencies. With the largest style it's 5.57 kB.
Photos when users have them Pass src. If it's missing or broken, the generated face stays.
Brand colors and dark mode Colors are CSS variables, so you restyle without regenerating
Accessible initials Letters clear WCAG AA (4.5:1) on every palette, checked for 2,100 seed/palette pairs
No license review MIT for the code and the art

Using it

Install the package for your framework. Each one pulls in the core.

npm i @inlaylab/opaline-react # or -vue, -svelte, -angular, -element 

React (server-component safe, React 18 or later):

import { Avatar, AvatarGroup } from '@inlaylab/opaline-react' import { clay } from '@inlaylab/opaline/clay' <Avatar seed={user.email} look={clay} src={user.photo} alt={user.name} /> <AvatarGroup look={clay} members={team.map((u) => u.email)} max={4} alt="Project members" /> 

Vue 3.5+ / Nuxt:

<Avatar :seed="user.email" :look="clay" :src="user.photo" :alt="user.name" /> 

Svelte 5.20+ / SvelteKit:

<Avatar seed={user.email} look={clay} src={user.photo} alt={user.name} /> 

Angular 20–22 (standalone, OnPush, works zoneless):

<op-avatar [seed]="email" [look]="clay" [src]="photo" alt="Ada" /> 

No framework:

<script type="module">import '@inlaylab/opaline-element'</script> <opaline-avatar seed="ada@inlay.dev" look="clay" finish="gloss" size="64" alt="Ada"></opaline-avatar> 

No DOM at all. createAvatar returns a plain string, so it works in an API route, a build script or an email template:

import { createAvatar } from '@inlaylab/opaline' import { clay } from '@inlaylab/opaline/clay' const { svg } = createAvatar(clay, { seed: 'ada@inlay.dev', body: 'hood', ears: 'bunny' }) 

createAvatar code next to the avatar it generates

Any option you set pins that part of the avatar. The seed fills in the rest.

Need a PNG for an OG card, a push notification or a profile-photo upload? toPng (688 B) does it in the browser with no dependencies:

import { toPng } from '@inlaylab/opaline/png' const blob = await toPng(createAvatar(clay, { seed }), { size: 512 }) 

Now the interesting part: how "same bytes everywhere" actually holds.

1. The seed is the only input, so normalize it first

Users type Ada@Inlay.dev with a trailing space on one form and ada@inlay.dev on another. If those give different faces, you get a bug report. So every seed goes through this first:

const EMAIL = /^[^\s@]+@[^\s@]+$/ export function normalizeSeed(seed: string | number | null | undefined): string { const s = String(seed ?? '').trim().normalize('NFC') return EMAIL.test(s) ? s.toLowerCase() : s } 

NFC matters more than it looks. Zoë can arrive as one code point (ë) or two (e plus a combining diaeresis), depending on the OS and keyboard. Without NFC those are two different people.

Only emails are lower-cased. Usernames like Ada and ada can be different accounts, so they keep their case.

2. No Math.random(): a seeded PRNG with forks

The seed goes through xmur3, a small string hash, which seeds sfc32, a fast 32-bit PRNG. Both fit in a few lines and give identical results on every JavaScript engine, because they use only Math.imul, shifts and | 0:

export function createRng(seed: string): Rng { const h = xmur3(seed) const next = sfc32(h(), h(), h(), h()) // Early sfc32 outputs are weakly mixed; skip them. for (let i = 0; i < 12; i++) next() // ... return { next, float, int, pick, // A fork depends only on the seed and label, never on earlier draws. fork: (label) => createRng(seed + '\u0000' + label), } } 

The fork line is the most important decision in the library.

A naive generator draws everything from one stream: shape, then color, then eyes, then mouth. Now add a new eyebrow option in a later release. It takes one draw from the stream, and every draw after it shifts. Every existing user's mouth changes. That's a breaking change you didn't mean to ship.

With forks, each feature gets its own stream, keyed by the seed and a label:

const g = rng.fork('color') const hue = g.pick(palette.hues) + g.float(-9, 9) 

fork('color') gives the same numbers whether it's called first or last, or after ten new features. New features get new labels. Old faces never move.

The \u0000 separator stops a seed of "ab" with label "c" from colliding with seed "a" and label "bc".

3. SVG IDs that are unique per page and identical across hydration

SVG gradients and filters need ids. That creates two separate bugs:

  1. Two avatars on one page share id="grad". The second one silently uses the first one's gradient, so the wrong colors appear.
  2. The server and the client invent different IDs. You get a hydration mismatch.

Opaline builds every ID from two parts:

const id = createIds( hash(seed + '\u0000' + style.name + '\u0000' + stableStringify(options)), instance, ) // id('grad') -> "a<7-char hash>-grad-<instance>" 
  • The prefix is a hash of the seed, the style and the options. The same seed with different options never shares <defs>. stableStringify sorts the keys, so option order never matters.
  • The instance suffix comes from each framework's SSR-safe ID primitive:
Package Instance from Minimum version
React useId() React 18, the version that introduced useId
Vue useId() Vue 3.5. Multiple apps on a page set app.config.idPrefix.
Svelte $props.id() Svelte 5.20
Angular An injectable per-app counter. Server and client render in the same order, so the numbers match. Angular 20
Web Component A module counter. There is no SSR, so only uniqueness matters. Any modern browser

That table is also why the minimum versions are what they are. 1.1.2 lowered React to 18 for exactly this reason: useId exists there, so nothing else was blocking it.

4. Defaults are invisible

One rule turned out to matter a lot: an option set to its default must give byte-for-byte the same output as leaving it unset.

Without that rule, <Avatar finish="soft"> and <Avatar> hash to different ID prefixes, so a refactor that spells out a default changes your markup. Each style declares its defaults, and createAvatar drops matching values before hashing:

for (const k in defaults) if (rest[k] === defaults[k]) delete rest[k] 

stableStringify also skips undefined values. Wrappers pass every prop through, and { shape: undefined } must not hash differently from {}.

Proof, not promises

"Deterministic" is an easy word to write in a README. Here is how the repo checks it.

A frozen checksum file: 4,000 hashes

SSR

frozen-1.json holds 4,000 SHA-256 digests (first 16 hex characters):

  • 100 seeds shaped like real input: emails, Person B12-style names, base36 IDs, plain numbers, and Unicode with emoji (Zoë Nguyễn 4 😀).
  • 40 option sets across all five styles: every finish, every shape, palettes, hood and ears, custom initials text.
test(`1.x output is frozen: ${key}`, () => { const now = FROZEN_SEEDS.map((seed) => digest(createAvatar(style, { ...opts, seed }).svg)) const changed = FROZEN_SEEDS.filter((_, i) => now[i] !== manifest[key]?.[i]) expect(changed, `Output changed for ${key}...`).toEqual([]) }) 

If one byte of one avatar changes, CI fails and names the seed. A second test checks that the manifest still covers every case, so nobody can make the guarantee pass by deleting rows. The only way to change the output is to regenerate the file on purpose, and that means a major version.

That's what lets you cache an avatar on a CDN forever, store the SVG in a database, or put it in an email that will be opened in three years.

Real SSR apps, hydrated in a real browser

Four sample apps live in the repo: Next (App Router, RSC), Nuxt, SvelteKit and Angular SSR (zoneless). For each one, Playwright:

  1. starts the production server and fetches the HTML;
  2. asserts that every id in the page is unique, and that the named avatar has role="img" and aria-label;
  3. loads the page in Chromium and fails on any console message matching /hydrat/i, any error and any page exception;
  4. compares the hydrated DOM with the server HTML: every avatar's root attributes and its <svg> must be exactly what the server sent.

Step 4 exists because of something I found while writing step 3. Production Svelte repairs a hydration mismatch silently. No warning, no error. The page looks fine, the console is clean, and the bug is invisible. Watching the console isn't enough. You have to diff the DOM.

Size, enforced

size-limit runs in CI with a budget for every entry point. These are today's numbers, minified and gzipped:

Import Size Budget
createAvatar (core) 1.27 kB 3 kB
core + Aura 2.52 kB 4 kB
core + Initials 2.86 kB 4 kB
core + Morph 4.90 kB 5 kB
core + Gradient 5.13 kB 5.25 kB
core + Clay 5.57 kB 8 kB
React wrapper (avatar + group) 854 B 1.5 kB
PNG export 688 B 1 kB

Styles are separate imports, so you only ship the ones you use.

Speed

A perf test builds 500 Clay avatars with the gloss finish, the heaviest combination. It warms the JIT, then keeps the best of five runs. On my laptop (Node 24) that's 56–63 ms, about 120 µs per avatar. CI fails above 250 ms, so a slow shared runner doesn't flake the build. A member list of 200 people costs you around 25 ms of CPU, once.

What broke along the way

I broke my own promise once, on purpose. 1.1.0 shipped Initials with white letters. On the brightest tiles that was as low as 1.9:1 contrast, far below the 4.5:1 that WCAG AA asks for. Shipping unreadable letters for the rest of 1.x seemed worse than changing them. So 1.1.1 changed exactly one thing: the <text> fill on non-pastel palettes is now a deep shade of the tile's own hue. Nothing else changed: no shapes, no other styles. A new test checks 300 seeds on each of the 7 palettes and fails if any pair falls below 4.5:1. The README lists this as the one deliberate exception.

useId output isn't a valid SVG ID fragment. Depending on the version, React's IDs contain characters such as : (:r1: in React 18) or «». Those characters cause trouble inside url(#…) references, so the wrapper strips everything except word characters and dashes before it uses the suffix.

undefined changed the hash. Wrappers forward every prop, so the core saw { shape: undefined } and hashed it differently from {}. The result was a different ID prefix for the same avatar, depending on which wrapper rendered it. That's why stableStringify now filters undefined values.

A silent Svelte fix hid a real mismatch, as described above. That's why the e2e diffs the DOM and doesn't trust the console.

When not to use it

  • You want dozens of art styles, or an image URL from a hosted HTTP API with no code at all: use DiceBear. It's excellent.
  • You're React-only and want minimal abstract shapes in your exact brand palette: Boring Avatars is lovely.
  • You need server-side PNG with zero setup: Opaline's forRaster() prepares the SVG, but you still bring a rasterizer such as @resvg/resvg-js.

The comparison page goes row by row, including the rows where the others win.

Try it

  • 🎨 Playground: type your own name. Every setting lives in the URL, so you can share an exact avatar.
  • ⭐ GitHub: MIT for code and art.
  • 📦 npm i @inlaylab/opaline

One question I'd really like your take on: is "byte-identical within a major version" the right contract? Or would you rather have "looks the same" and let me fix rendering details in minor releases? I went with bytes because it makes CDN caching and email trivially safe, but it does make every bug fix a judgement call.