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
.glbmodel 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" });
| Category | Outfits |
|---|---|
| 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).
| Prop | Default | Description |
|---|---|---|
seed | required | Any string; the same seed is always the same face. |
size | 64 | Displayed size in CSS pixels. Images render at 2x for sharp screens. |
look | "3d" | "3d", "toon" or "flat". |
live | false | An animated 3D canvas (drag to rotate) instead of an image. |
shape | "circle" | "circle", "rounded" or "square". |
transparent, alt | No background color; alt text. | |
| Any trait option | hair, 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
AvatarFactorystarts its own worker pool (up to 4 workers). - Lower the quality for small images.
quality: 0.75is 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
disposeAvataron avatars you no longer show, andfactory.dispose()to stop the workers. - Limit live canvases. Every
AvatarStageowns 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.
| Method | Description |
|---|---|
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.
| Option | Default | Description |
|---|---|---|
canvas | new canvas | The canvas to draw into. Read it from stage.renderer.domElement if you didn't pass one. |
transparent | false | Transparent background instead of the avatar's colour. |
pixelRatio | device, max 2 | Render resolution multiplier. |
preserveDrawingBuffer | false | Keep the image so the canvas can be read back at any time. |
| Member | Description |
|---|---|
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, renderer | The 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.
| Option | Default | Description |
|---|---|---|
size | 256 | Width and height in pixels. |
framing | "bust" | "head" or "bust". |
transparent | false | Drop the background colour. |
type | "image/png" | "image/png", "image/webp" or "image/jpeg". |
quality | browser default | 0 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.
| Option | Values |
|---|---|
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). |
skin | Index 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" |
outfit | Any garment in the wardrobe (22 of them) |
outfitColor, accentColor | Any 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" |
background | Any hex colour |
quality | Mesh 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:
| Field | Description |
|---|---|
userData.params | The AvatarParams it was built from. |
userData.tick(t) | Advance the idle animation to time t in seconds. |
userData.top | The 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.
| Option | Default | Description |
|---|---|---|
size | 256 | Width and height in pixels. The drawing is a vector, so any size stays sharp. |
transparent | false | Leave out the background colour. |
shape | "square" | "square", "rounded" or "circle". |
| Related | Description |
|---|---|
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:
| Export | Contents |
|---|---|
SKIN_TONES | 14 hex colours, deepest to lightest |
HAIR_STYLE_LIST | All 16 hairstyles |
OUTFIT_LIST, WARDROBE | All 22 garments; WARDROBE adds each one's category and default colors |
ACCESSORY_LIST | The accessories |
EXPRESSION_LIST | All 7 expressions |
BACKGROUNDS | The 9 default background colours |
HAIR_COLORS, EYE_COLORS, CLOTH_COLORS | Named 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, thenstage.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.AvatarStagesets 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. Addworker-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.
generateAvatarand 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.