<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Bhavik Bhensajaliya</title>
    <description>The latest articles on DEV Community by Bhavik Bhensajaliya (@marvelous_dev_79adb65f2cc).</description>
    <link>https://dev.to/marvelous_dev_79adb65f2cc</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2349701%2Fb91dfdc5-1bb4-4263-9157-d5c7341f7021.png</url>
      <title>DEV Community: Bhavik Bhensajaliya</title>
      <link>https://dev.to/marvelous_dev_79adb65f2cc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/marvelous_dev_79adb65f2cc"/>
    <language>en</language>
    <item>
      <title>How I made SVG avatars byte-identical on server and client</title>
      <dc:creator>Bhavik Bhensajaliya</dc:creator>
      <pubDate>Sat, 07 Nov 2026 13:57:45 +0000</pubDate>
      <link>https://dev.to/marvelous_dev_79adb65f2cc/how-i-made-svg-avatars-byte-identical-on-server-and-client-hmb</link>
      <guid>https://dev.to/marvelous_dev_79adb65f2cc/how-i-made-svg-avatars-byte-identical-on-server-and-client-hmb</guid>
      <description>&lt;p&gt;Open the member list in almost any app. Most people never upload a photo, so you see a column of identical grey silhouettes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8h75fdqsh8y9mo4sgtfq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8h75fdqsh8y9mo4sgtfq.png" alt="when not to use it" width="800" height="377"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The usual fixes each come with a catch:&lt;/p&gt;

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

&lt;p&gt;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 &lt;a href="https://github.com/Inlaylab/Opaline" rel="noopener noreferrer"&gt;Opaline&lt;/a&gt;. This post covers what it gives you, how to use it, and the engineering that makes the promise hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you might want it
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  Using it
&lt;/h2&gt;

&lt;p&gt;Install the package for your framework. Each one pulls in the core.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i @inlaylab/opaline-react   &lt;span class="c"&gt;# or -vue, -svelte, -angular, -element&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;React&lt;/strong&gt; (server-component safe, React 18 or later):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Avatar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;AvatarGroup&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@inlaylab/opaline-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;clay&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@inlaylab/opaline/clay&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Avatar&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;look&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;clay&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;alt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AvatarGroup&lt;/span&gt; &lt;span class="nx"&gt;look&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;clay&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;members&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;team&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;alt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Project members&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Vue 3.5+ / Nuxt:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Avatar&lt;/span&gt; &lt;span class="na"&gt;:seed=&lt;/span&gt;&lt;span class="s"&gt;"user.email"&lt;/span&gt; &lt;span class="na"&gt;:look=&lt;/span&gt;&lt;span class="s"&gt;"clay"&lt;/span&gt; &lt;span class="na"&gt;:src=&lt;/span&gt;&lt;span class="s"&gt;"user.photo"&lt;/span&gt; &lt;span class="na"&gt;:alt=&lt;/span&gt;&lt;span class="s"&gt;"user.name"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Svelte 5.20+ / SvelteKit:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight svelte"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Avatar&lt;/span&gt; &lt;span class="na"&gt;seed=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;look=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;clay&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Angular 20–22&lt;/strong&gt; (standalone, OnPush, works zoneless):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;op-avatar&lt;/span&gt; &lt;span class="na"&gt;[seed]=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;[look]=&lt;/span&gt;&lt;span class="s"&gt;"clay"&lt;/span&gt; &lt;span class="na"&gt;[src]=&lt;/span&gt;&lt;span class="s"&gt;"photo"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Ada"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;No framework:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@inlaylab/opaline-element&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;opaline-avatar&lt;/span&gt; &lt;span class="na"&gt;seed=&lt;/span&gt;&lt;span class="s"&gt;"ada@inlay.dev"&lt;/span&gt; &lt;span class="na"&gt;look=&lt;/span&gt;&lt;span class="s"&gt;"clay"&lt;/span&gt; &lt;span class="na"&gt;finish=&lt;/span&gt;&lt;span class="s"&gt;"gloss"&lt;/span&gt; &lt;span class="na"&gt;size=&lt;/span&gt;&lt;span class="s"&gt;"64"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Ada"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/opaline-avatar&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;No DOM at all.&lt;/strong&gt; &lt;code&gt;createAvatar&lt;/code&gt; returns a plain string, so it works in an API route, a build script or an email template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createAvatar&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@inlaylab/opaline&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;clay&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@inlaylab/opaline/clay&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;svg&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createAvatar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ada@inlay.dev&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hood&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ears&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bunny&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flk8bcu9lnrdlwy57g8lq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flk8bcu9lnrdlwy57g8lq.png" alt="createAvatar code next to the avatar it generates" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Any option you set pins that part of the avatar. The seed fills in the rest.&lt;/p&gt;

&lt;p&gt;Need a PNG for an OG card, a push notification or a profile-photo upload? &lt;code&gt;toPng&lt;/code&gt; (688 B) does it in the browser with no dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;toPng&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@inlaylab/opaline/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;toPng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;createAvatar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the interesting part: how "same bytes everywhere" actually holds.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The seed is the only input, so normalize it first
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;EMAIL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[^\s&lt;/span&gt;&lt;span class="sr"&gt;@&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+@&lt;/span&gt;&lt;span class="se"&gt;[^\s&lt;/span&gt;&lt;span class="sr"&gt;@&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizeSeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NFC&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;EMAIL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Only emails are lower-cased. Usernames like &lt;code&gt;Ada&lt;/code&gt; and &lt;code&gt;ada&lt;/code&gt; can be different accounts, so they keep their case.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. No Math.random(): a seeded PRNG with forks
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createRng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Rng&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;xmur3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sfc32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;h&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;h&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;h&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;h&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="c1"&gt;// Early sfc32 outputs are weakly mixed; skip them.&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// A fork depends only on the seed and label, never on earlier draws.&lt;/span&gt;
    &lt;span class="na"&gt;fork&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;createRng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;u0000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;fork&lt;/code&gt; line is the most important decision in the library.&lt;/p&gt;

&lt;p&gt;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. &lt;strong&gt;Every existing user's mouth changes.&lt;/strong&gt; That's a breaking change you didn't mean to ship.&lt;/p&gt;

&lt;p&gt;With forks, each feature gets its own stream, keyed by the seed and a label:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fork&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;color&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hues&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fork('color')&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;\u0000&lt;/code&gt; separator stops a seed of &lt;code&gt;"ab"&lt;/code&gt; with label &lt;code&gt;"c"&lt;/code&gt; from colliding with seed &lt;code&gt;"a"&lt;/code&gt; and label &lt;code&gt;"bc"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. SVG IDs that are unique per page and identical across hydration
&lt;/h2&gt;

&lt;p&gt;SVG gradients and filters need &lt;code&gt;id&lt;/code&gt;s. That creates two separate bugs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Two avatars on one page share &lt;code&gt;id="grad"&lt;/code&gt;.&lt;/strong&gt; The second one silently uses the first one's gradient, so the wrong colors appear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The server and the client invent different IDs.&lt;/strong&gt; You get a hydration mismatch.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Opaline builds every ID from two parts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createIds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;u0000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;u0000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;stableStringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="nx"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// id('grad') -&amp;gt; "a&amp;lt;7-char hash&amp;gt;-grad-&amp;lt;instance&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The prefix&lt;/strong&gt; is a hash of the seed, the style and the options. The same seed with different options never shares &lt;code&gt;&amp;lt;defs&amp;gt;&lt;/code&gt;. &lt;code&gt;stableStringify&lt;/code&gt; sorts the keys, so option order never matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The instance suffix&lt;/strong&gt; comes from each framework's SSR-safe ID primitive:&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Instance from&lt;/th&gt;
&lt;th&gt;Minimum version&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;React&lt;/td&gt;
&lt;td&gt;&lt;code&gt;useId()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;React 18, the version that introduced &lt;code&gt;useId&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vue&lt;/td&gt;
&lt;td&gt;&lt;code&gt;useId()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Vue 3.5. Multiple apps on a page set &lt;code&gt;app.config.idPrefix&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Svelte&lt;/td&gt;
&lt;td&gt;&lt;code&gt;$props.id()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Svelte 5.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Angular&lt;/td&gt;
&lt;td&gt;An injectable per-app counter. Server and client render in the same order, so the numbers match.&lt;/td&gt;
&lt;td&gt;Angular 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web Component&lt;/td&gt;
&lt;td&gt;A module counter. There is no SSR, so only uniqueness matters.&lt;/td&gt;
&lt;td&gt;Any modern browser&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;h2&gt;
  
  
  4. Defaults are invisible
&lt;/h2&gt;

&lt;p&gt;One rule turned out to matter a lot: &lt;strong&gt;an option set to its default must give byte-for-byte the same output as leaving it unset.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Without that rule, &lt;code&gt;&amp;lt;Avatar finish="soft"&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;Avatar&amp;gt;&lt;/code&gt; hash to different ID prefixes, so a refactor that spells out a default changes your markup. Each style declares its defaults, and &lt;code&gt;createAvatar&lt;/code&gt; drops matching values before hashing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;stableStringify&lt;/code&gt; also skips &lt;code&gt;undefined&lt;/code&gt; values. Wrappers pass every prop through, and &lt;code&gt;{ shape: undefined }&lt;/code&gt; must not hash differently from &lt;code&gt;{}&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof, not promises
&lt;/h2&gt;

&lt;p&gt;"Deterministic" is an easy word to write in a README. Here is how the repo checks it.&lt;/p&gt;

&lt;h3&gt;
  
  
  A frozen checksum file: 4,000 hashes
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0e14zxgfssidzpuqfg8y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0e14zxgfssidzpuqfg8y.png" alt="SSR" width="800" height="326"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;frozen-1.json&lt;/code&gt; holds 4,000 SHA-256 digests (first 16 hex characters):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;100 seeds&lt;/strong&gt; shaped like real input: emails, &lt;code&gt;Person B12&lt;/code&gt;-style names, base36 IDs, plain numbers, and Unicode with emoji (&lt;code&gt;Zoë Nguyễn 4 😀&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;40 option sets&lt;/strong&gt; across all five styles: every finish, every shape, palettes, hood and ears, custom initials text.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`1.x output is frozen: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;FROZEN_SEEDS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;createAvatar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;changed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;FROZEN_SEEDS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]?.[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`Output changed for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;...`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([])&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real SSR apps, hydrated in a real browser
&lt;/h3&gt;

&lt;p&gt;Four sample apps live in the repo: &lt;strong&gt;Next (App Router, RSC), Nuxt, SvelteKit and Angular SSR (zoneless)&lt;/strong&gt;. For each one, Playwright:&lt;/p&gt;

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

&lt;p&gt;Step 4 exists because of something I found while writing step 3. &lt;strong&gt;Production Svelte repairs a hydration mismatch silently.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Size, enforced
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;size-limit&lt;/code&gt; runs in CI with a budget for every entry point. These are today's numbers, minified and gzipped:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Import&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Budget&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;createAvatar&lt;/code&gt; (core)&lt;/td&gt;
&lt;td&gt;1.27 kB&lt;/td&gt;
&lt;td&gt;3 kB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;core + Aura&lt;/td&gt;
&lt;td&gt;2.52 kB&lt;/td&gt;
&lt;td&gt;4 kB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;core + Initials&lt;/td&gt;
&lt;td&gt;2.86 kB&lt;/td&gt;
&lt;td&gt;4 kB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;core + Morph&lt;/td&gt;
&lt;td&gt;4.90 kB&lt;/td&gt;
&lt;td&gt;5 kB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;core + Gradient&lt;/td&gt;
&lt;td&gt;5.13 kB&lt;/td&gt;
&lt;td&gt;5.25 kB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;core + Clay&lt;/td&gt;
&lt;td&gt;5.57 kB&lt;/td&gt;
&lt;td&gt;8 kB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React wrapper (avatar + group)&lt;/td&gt;
&lt;td&gt;854 B&lt;/td&gt;
&lt;td&gt;1.5 kB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PNG export&lt;/td&gt;
&lt;td&gt;688 B&lt;/td&gt;
&lt;td&gt;1 kB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Styles are separate imports, so you only ship the ones you use.&lt;/p&gt;

&lt;h3&gt;
  
  
  Speed
&lt;/h3&gt;

&lt;p&gt;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 &lt;strong&gt;56–63 ms, about 120 µs per avatar&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke along the way
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;I broke my own promise once, on purpose.&lt;/strong&gt; 1.1.0 shipped Initials with white letters. On the brightest tiles that was as low as &lt;strong&gt;1.9:1&lt;/strong&gt; 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 &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; 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.&lt;/p&gt;

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

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

&lt;p&gt;&lt;strong&gt;A silent Svelte fix hid a real mismatch&lt;/strong&gt;, as described above. That's why the e2e diffs the DOM and doesn't trust the console.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to use it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You want dozens of art styles, or an image URL from a hosted HTTP API with no code at all: use &lt;a href="https://www.dicebear.com/" rel="noopener noreferrer"&gt;DiceBear&lt;/a&gt;. It's excellent.&lt;/li&gt;
&lt;li&gt;You're React-only and want minimal abstract shapes in your exact brand palette: &lt;a href="https://boringavatars.com/" rel="noopener noreferrer"&gt;Boring Avatars&lt;/a&gt; is lovely.&lt;/li&gt;
&lt;li&gt;You need server-side PNG with zero setup: Opaline's &lt;code&gt;forRaster()&lt;/code&gt; prepares the SVG, but you still bring a rasterizer such as &lt;code&gt;@resvg/resvg-js&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://inlaylab.github.io/Opaline/compare/" rel="noopener noreferrer"&gt;comparison page&lt;/a&gt; goes row by row, including the rows where the others win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;🎨 &lt;strong&gt;&lt;a href="https://inlaylab.github.io/Opaline/playground/" rel="noopener noreferrer"&gt;Playground&lt;/a&gt;&lt;/strong&gt;: type your own name. Every setting lives in the URL, so you can share an exact avatar.&lt;/li&gt;
&lt;li&gt;⭐ &lt;strong&gt;&lt;a href="https://github.com/Inlaylab/Opaline" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/strong&gt;: MIT for code and art.&lt;/li&gt;
&lt;li&gt;📦 &lt;code&gt;npm i @inlaylab/opaline&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One question I'd really like your take on: &lt;strong&gt;is "byte-identical within a major version" the right contract?&lt;/strong&gt; 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.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>react</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
