Docs

Using Zuri

@brightkumedzro/zuri-avatar turns any string (a user ID, an email, a name) into a unique 3D avatar. The same string always gives the same face, so there is nothing to upload, store or sync. Everything is generated from code in the browser with three.js.

There are three styles, and the same seed gives the same person in all of them:

  • Toon 3D: an animated-film look with big expressive eyes and sculpted, glossy hair. Same outputs as 3D. See Toon 3D.
  • 3D: live in a canvas (it blinks, sways and can be rotated), as a PNG/WebP/JPEG image, or as a .glb model for Blender, Unity and AR.
  • Flat 2D: a vector illustration as a small SVG string. It needs no WebGL, so it also works on servers, in emails and in native apps. See Flat 2D avatars.

Full-body characters and realistic digital humans are planned.

Not a developer? Use the builder to design a face and download it as a PNG or .glb, no code needed.

Install

For 3D, install the package with three.js (version 0.170 or newer):

npm i @brightkumedzro/zuri-avatar three

For flat 2D avatars only, you don't need three.js:

npm i @brightkumedzro/zuri-avatar

The package ships ES modules with TypeScript types. The 3D mesher runs in Web Workers that are built into the package, so there is nothing to configure in Vite, webpack, Next.js or any other bundler.

Quick start

Show a live avatar in a canvas:

<canvas id="avatar" width="256" height="256"></canvas>

<script type="module">
  import { AvatarFactory, AvatarStage } from "@brightkumedzro/zuri-avatar";

  const stage = new AvatarStage({ canvas: document.getElementById("avatar"), transparent: true });
  stage.setSize(256, 256);
  stage.show(await new AvatarFactory().create("ada@example.com"));
  stage.render();
</script>

Or get an image you can put in any <img>:

import { AvatarFactory, snapshotAvatar } from "@brightkumedzro/zuri-avatar";

const zuri = new AvatarFactory();
const avatar = await zuri.create(user.id);
img.src = snapshotAvatar(avatar, { size: 256 });

Live 3D on a page

AvatarStage is a ready-lit scene with a camera that frames the avatar. Give it a canvas, size it, show an avatar and call render every frame to animate the idle blinks and sway.

import { AvatarFactory, AvatarStage } from "@brightkumedzro/zuri-avatar";

const canvas = document.querySelector("canvas");
const stage = new AvatarStage({ canvas, transparent: true });
stage.setSize(canvas.clientWidth, canvas.clientHeight);
stage.frame("head");                          // or "bust" (default)
stage.show(await new AvatarFactory().create("kwame"));

function loop(ms) {
  stage.render(ms / 1000);                     // time in seconds drives blinks and sway
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

Calling show again swaps in a new avatar and frees the old one. Call setSize whenever the canvas changes size (a ResizeObserver works well), and stage.dispose() when you remove the canvas.

To let people rotate the avatar, change stage.avatar.rotation.y from pointer events. The avatar is a normal THREE.Group.

Images (PNG, WebP)

snapshotAvatar renders an avatar facing forward with its eyes open and returns a data URL. All snapshots share one offscreen renderer, so taking many is cheap.

import { AvatarFactory, disposeAvatar, snapshotAvatar } from "@brightkumedzro/zuri-avatar";

const zuri = new AvatarFactory();

async function avatarUrl(seed, size = 96) {
  const avatar = await zuri.create(seed, { quality: 0.75 });
  const url = snapshotAvatar(avatar, { size, type: "image/webp", quality: 0.9 });
  disposeAvatar(avatar);                       // free GPU memory once you have the image
  return url;
}

Render at twice the displayed size for sharp images on high-density screens (a 48 px avatar needs size: 96). Pass transparent: true to drop the coloured background.

To save a file, turn the data URL into a download link, or fetch(url).then(r => r.blob()) to upload it.

In a three.js scene

If you already have a scene, add the avatar to it like any other object. It is about 3.5 units tall from shoulders to hair, with the head near the origin, facing +Z. Your scene needs lights; MeshPhysicalMaterial looks best with an environment map.

import { AvatarFactory } from "@brightkumedzro/zuri-avatar";

const avatar = await new AvatarFactory().create("ada");
avatar.position.set(0, 1.5, 0);
scene.add(avatar);

renderer.setAnimationLoop((ms) => {
  avatar.userData.tick(ms / 1000);           // idle blinking + sway
  renderer.render(scene, camera);
});

When you remove it, call disposeAvatar(avatar) to free its geometries, materials and textures.

Customising traits

Every avatar starts from its seed, and any option you pass overrides that one trait. Everything else stays the same, so a user keeps their face when they change their hair.

const avatar = await zuri.create(user.id, {
  hair: "locs",
  skin: 2,                    // index into SKIN_TONES (0 = deepest) or a hex like "#8b563a"
  expression: "grin",
  outfit: "hoodie",
  glasses: "round",
  background: "#cde2f7",
});

To let users personalise their avatar, store only the options they changed next to their ID, for example { "hair": "locs" }, and pass them back in. See AvatarOptions for every value.

Toon 3D

Pass style: "toon" for an animated-film look: a bigger, rounder head, large eyes with big catchlights, sculpted brows, chunky glossy hair and smooth skin. It's the same person as the default "soft" style, and everything else (live 3D, images, .glb, every trait option) works the same way.

const avatar = await zuri.create(user.id, { style: "toon", hair: "afro" });
img.src = snapshotAvatar(avatar, { size: 256 });

createAvatar and renderAvatarImage accept style too.

Wardrobe

Any avatar can wear any garment, in every style. Pass outfit to choose one, and outfitColor and accentColor (as #rrggbb) to recolor it. The accent colors ties, bows, trims, buttons and scarves.

const avatar = await zuri.create(user.id, {
  outfit: "kente",
  accessory: "headtie",        // a gele, in the avatar's print colors
});

const doctor = await zuri.create(user.id, { outfit: "labcoat", accentColor: "#2f5d8a" });
CategoryOutfits
Everyday"tee", "vneck", "hoodie", "shirt" (polo), "turtleneck", "tank", "sweater", "cardigan", "buttonup", "denim"
Formal"blazer", "suit", "blouse", "wrap", "bowtie"
Traditional"kaftan" (dashiki), "kente", "ankara", "agbada"
Work"labcoat", "scrubs", "chef"

Accessories go on top: accessory: "scarf", "necklace" or "headtie" (which covers the hair). Some garments have their own default color, like a white lab coat or blue denim; outfitColor overrides it.

Faces generated from a seed only ever wear the original seven tops. Everything else is opt-in, so new clothes never change an existing user's avatar. WARDROBE lists every garment with its category, for building your own picker.

Exporting .glb

Use three.js's GLTFExporter to save an avatar as a binary glTF file:

import { GLTFExporter } from "three/addons/exporters/GLTFExporter.js";

const avatar = await zuri.create("hiro");
const glb = await new GLTFExporter().parseAsync(avatar, { binary: true });
const url = URL.createObjectURL(new Blob([glb], { type: "model/gltf-binary" }));

The file includes the painted face texture and materials. Idle animation is not exported.

Flat 2D avatars

The flat style draws the same traits (skin tone, hair, outfit, expression, glasses and so on) as a vector illustration. It returns an SVG string of about 4 KB, runs anywhere JavaScript does, and doesn't need three.js. Import it from @brightkumedzro/zuri-avatar/flat to keep three.js out of your bundle.

import { createFlatAvatar, flatAvatarDataURL } from "@brightkumedzro/zuri-avatar/flat";

const svg = createFlatAvatar(user.id, { size: 96, shape: "circle" });
img.src = flatAvatarDataURL(svg);              // or insert the SVG markup directly

Every AvatarOptions override works here too, plus size, transparent and shape ("square", "rounded" or "circle"). See createFlatAvatar.

On a server

// Express, Next.js route handlers, Cloudflare Workers… anything that can return text.
app.get("/avatars/:id.svg", (req, res) => {
  res.type("image/svg+xml").set("Cache-Control", "public, max-age=31536000, immutable");
  res.send(createFlatAvatar(req.params.id, { size: 256 }));
});

Because the same ID always gives the same picture, these responses can be cached forever. In the browser, rasterizeSVG(svg, { size: 512 }) turns the SVG into a PNG data URL when you need a bitmap.

React, Vue, Svelte and others

There are ready-made <Avatar> components for React, Vue and Svelte. They take the same props, work with server rendering, and share one worker pool per page. Flat avatars render on the server; 3D and toon avatars show a placeholder in the avatar's background color and fill in in the browser, so hydration always matches. Each one was tested in a fresh app: Next.js 16 (React 19), Nuxt 4 and SvelteKit 3 (Svelte 5).

PropDefaultDescription
seedrequiredAny string; the same seed is always the same face.
size64Displayed size in CSS pixels. Images render at 2x for sharp screens.
look"3d""3d", "toon" or "flat".
livefalseAn animated 3D canvas (drag to rotate) instead of an image.
shape"circle""circle", "rounded" or "square".
transparent, altNo background color; alt text.
Any trait optionhair, skin, outfit, faceShape, presentation, accessory…

React and Next.js

npm i @brightkumedzro/zuri-react three
import { Avatar } from "@brightkumedzro/zuri-react";

<Avatar seed={user.id} />
<Avatar seed={user.id} look="toon" size={96} />
<Avatar seed={user.id} look="flat" />            // rendered on the server too
<Avatar seed={user.id} live size={200} hair="locs" outfit="kente" />

The component is marked "use client", so you can use it straight from Next.js server components.

Vue and Nuxt

npm i @brightkumedzro/zuri-vue three
<script setup>
import { Avatar } from "@brightkumedzro/zuri-vue";
</script>

<template>
  <Avatar :seed="user.id" look="toon" :size="96" />
</template>

Svelte and SvelteKit

npm i @brightkumedzro/zuri-svelte three
<script>
  import { Avatar } from "@brightkumedzro/zuri-svelte";
</script>

<Avatar seed={user.id} look="toon" size={96} />

Angular, plain JavaScript and everything else

Without a component, use the two browser helpers in the main package. They share the same worker pool and cache. This is how the components work inside, and it was tested in a plain webpack 5 app.

import { avatarImage, mountAvatar } from "@brightkumedzro/zuri-avatar";

// An image (cached, so repeated seeds are free)
img.src = await avatarImage(user.id, { size: 128, style: "toon" });

// Live 3D in a canvas: call update() to change it, dispose() when you remove it
const live = mountAvatar(canvas, user.id, { hair: "afro" });
live.update("someone-else");
live.dispose();

In Angular, call mountAvatar in ngAfterViewInit with your canvas (@ViewChild) and dispose() in ngOnDestroy. Angular and Solid have no ready-made component yet, and we haven't tested them, but the helpers are plain JavaScript.

Performance

  • Reuse one factory. Each AvatarFactory starts its own worker pool (up to 4 workers).
  • Lower the quality for small images. quality: 0.75 is noticeably faster and looks the same at thumbnail sizes.
  • Cache images. The same seed always gives the same picture, so you can keep data URLs in memory or localStorage.
  • Dispose. Call disposeAvatar on avatars you no longer show, and factory.dispose() to stop the workers.
  • Limit live canvases. Every AvatarStage owns a WebGL context, and browsers allow only about 16 at once.

API reference

createAvatar(seed, options?)

createAvatar(seed: string, options?: AvatarOptions & { quality?: number }): Avatar

Builds an avatar synchronously on the main thread. Simple, but it blocks for tens of milliseconds, so prefer AvatarFactory in apps.

AvatarFactory

new AvatarFactory(options?: { workers?: number })

Builds avatars on a pool of Web Workers. workers defaults to your CPU cores minus one, at most 4. Pass 0 to build on the main thread. Without Worker support, it falls back to the main thread automatically.

MethodDescription
create(seed, options?)Returns Promise<Avatar>. Accepts all AvatarOptions plus quality and style.
build(params, quality?)Builds from AvatarParams you got from generateAvatar (and possibly edited).
dispose()Stops all workers.

AvatarStage

new AvatarStage(options?: { canvas?, transparent?, pixelRatio?, preserveDrawingBuffer? })

A lit scene, camera and WebGL renderer for showing one avatar.

OptionDefaultDescription
canvasnew canvasThe canvas to draw into. Read it from stage.renderer.domElement if you didn't pass one.
transparentfalseTransparent background instead of the avatar's colour.
pixelRatiodevice, max 2Render resolution multiplier.
preserveDrawingBufferfalseKeep the image so the canvas can be read back at any time.
MemberDescription
show(avatar)Display an avatar, disposing the previous one.
setSize(w, h)Resize the canvas and reframe the camera.
frame("head" | "bust")Close-up of the head, or head and shoulders (default).
render(time?)Draw a frame. Pass time in seconds to animate blinks and sway.
dispose()Free the avatar and the renderer.
avatar, scene, camera, rendererThe underlying three.js objects, for anything else.

snapshotAvatar(avatar, options?)

snapshotAvatar(avatar: Avatar, options?: SnapshotOptions): string

Renders the avatar to a data URL. The avatar is not modified or disposed.

OptionDefaultDescription
size256Width and height in pixels.
framing"bust""head" or "bust".
transparentfalseDrop the background colour.
type"image/png""image/png", "image/webp" or "image/jpeg".
qualitybrowser default0 to 1, for WebP and JPEG.

renderAvatarImage(seed, options?)

renderAvatarImage(seed: string, options?: AvatarOptions & SnapshotOptions): string

Builds, snapshots and disposes in one call on the main thread. Handy for one image; for many, use AvatarFactory and snapshotAvatar.

generateAvatar(seed, options?)

generateAvatar(seed: string, options?: AvatarOptions): AvatarParams

Returns the plain-data description of an avatar (skin, face shape, eyes, hair, outfit, expression and so on) without building anything. It needs no WebGL, so it also runs on a server. Useful for showing trait names, or for editing a field and passing the result to factory.build(params).

import { generateAvatar } from "@brightkumedzro/zuri-avatar/params";

generateAvatar("ada").hair.style;              // e.g. "curly"

AvatarOptions

Every field is optional. Anything you leave out comes from the seed.

OptionValues
presentation"feminine", "masculine"
faceShape"oval", "round", "square", "heart", "long", "diamond". Each face still varies around its shape (jaw, chin, cheekbones, forehead, eye and nose placement).
skinIndex 0–13 into SKIN_TONES (0 = deepest), or any hex colour
hair"none", "buzz", "crop", "fade", "hightop", "afro", "puffs", "bun", "locs", "braids", "cornrows", "long", "bob", "ponytail", "curly", "headwrap"
outfitAny garment in the wardrobe (22 of them)
outfitColor, accentColorAny hex colour, for the garment and its trims
accessory"none", "scarf", "necklace", "headtie"
expression"neutral", "smile", "grin", "laugh", "surprised", "wink", "smirk"
glasses"none", "round", "square"
facialHair"none", "stubble", "mustache", "goatee", "beard"
backgroundAny hex colour
qualityMesh detail, default 1. 0.75 is faster for thumbnails.
style"soft" (default) or "toon". 3D only; see Toon 3D.

The Avatar object

An avatar is a THREE.Group with a few extras on userData:

FieldDescription
userData.paramsThe AvatarParams it was built from.
userData.tick(t)Advance the idle animation to time t in seconds.
userData.topThe y position of the top of the hair, for framing.

Free it with disposeAvatar(avatar) when you're done.

createFlatAvatar(seed, options?)

createFlatAvatar(seed: string, options?: AvatarOptions & FlatOptions): string

Returns a flat 2D avatar as an SVG string. Works in browsers, Node, Deno, Bun and workers.

OptionDefaultDescription
size256Width and height in pixels. The drawing is a vector, so any size stays sharp.
transparentfalseLeave out the background colour.
shape"square""square", "rounded" or "circle".
RelatedDescription
flatAvatarSVG(params, options?)Draw from AvatarParams you got from generateAvatar.
flatAvatarDataURL(svg)Turn the SVG into a URL for <img src> or CSS.
rasterizeSVG(svg, options?)Browser only. Returns a PNG, WebP or JPEG data URL. Takes size, type and quality.

avatarImage, mountAvatar

avatarImage(seed: string, options?: AvatarOptions & { size?, style?, framing?, transparent?, type? }): Promise<string>
mountAvatar(canvas: HTMLCanvasElement, seed: string, options?: AvatarOptions & { style?, framing?, transparent?, animate?, interactive? }): { update, dispose }

Browser helpers. avatarImage renders a 3D or toon avatar to a data URL on a shared worker pool and caches it. mountAvatar shows a live avatar in a canvas, sized to the canvas, with idle animation (off for users who prefer reduced motion) and drag to rotate. Both are safe to import during server rendering; call them in the browser.

Constants

Useful for building your own pickers:

ExportContents
SKIN_TONES14 hex colours, deepest to lightest
HAIR_STYLE_LISTAll 16 hairstyles
OUTFIT_LIST, WARDROBEAll 22 garments; WARDROBE adds each one's category and default colors
ACCESSORY_LISTThe accessories
EXPRESSION_LISTAll 7 expressions
BACKGROUNDSThe 9 default background colours
HAIR_COLORS, EYE_COLORS, CLOTH_COLORSNamed palettes

Determinism

The seed is hashed into a random number generator, and each group of traits (identity, shape, style, mood) uses its own stream. So overriding one trait never changes the others, and the same seed gives the same face on every device and browser.

Seeds are case-sensitive: "Ada" and "ada" are different faces. Normalise them (for example, lower-case emails) before passing them in. Every result carries params.version, which only changes in a release that alters existing faces. Version 2 added face shapes, so faces generated by version 1 look different.

Privacy

The library runs entirely in the browser and makes no network requests, so no personal data leaves the device. If you would rather not use emails as seeds, hash them first; the avatar is just as unique.

const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(email.toLowerCase()));
const seed = [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, "0")).join("");

Hosted API coming soon

A hosted image API is planned for apps that can't run WebGL, such as emails, native apps and server rendering. Avatars will be plain URLs, cached on a CDN:

https://api.zuri.dev/avatar/{seed}.png?size=256
https://api.zuri.dev/avatar/{seed}.glb

It isn't live yet. Email us to hear when it is.

Troubleshooting

The canvas is blank.
Call stage.setSize() with non-zero values, then stage.render(). Nothing draws until you render.
The avatar looks black in my own scene.
Add lights, or an environment map such as three.js's RoomEnvironment. AvatarStage sets these up for you.
Avatars build slowly, or the console mentions a Content Security Policy.
The workers start from a blob: URL. If your CSP blocks that, Zuri falls back to the main thread, which works but can stutter. Add worker-src blob: to your policy to keep the workers.
"window is not defined" or "document is not defined".
The code ran on the server. Only build and render 3D avatars in the browser. generateAvatar and the flat style (@brightkumedzro/zuri-avatar/flat) work anywhere.
The browser warns about too many WebGL contexts.
Use images for lists, keep live stages to a few, and call stage.dispose() when you remove one.

Still stuck? Open an issue on GitHub, email us or reach out on LinkedIn.