Creating Custom Styles
An “avatar style” is just an object that implements the Style<T> contract
from @avatar-generator/core. You can use styles privately in your app or
publish them as reusable npm packages. This guide walks through building
one from scratch.
The Style<T> contract
A style needs two things: a name and a create(options) method that
returns an AvatarResult.
import type { AvatarOptions, AvatarResult, Style } from "@avatar-generator/core";
interface Style<T extends AvatarOptions> { name: string; create(options: T): AvatarResult;}
interface AvatarResult { svg: string; toDataUri(): string;}Your create function receives the merged options (defaults already
applied by createAvatar) and must return an SVG string plus a helper
that serializes it to a data URI.
The tools core gives you
You almost never need to handcraft SVG from scratch — core ships the primitives every bundled style uses:
| Helper | What it does |
|---|---|
createRandom(seed) | Deterministic RNG: next, int, pick, bool, shuffle |
buildSvg(content, opts, bg) | Wraps your SVG body in the right viewBox, clip path, border, transforms |
escapeXml(text) | Escapes text for use inside SVG <text> nodes |
DEFAULT_COLORS | Vivid 10-color palette used as a fallback |
SKIN_TONES, EYE_COLORS | Curated palettes for face-based styles |
validateOption(...) | Throws a descriptive error if a string option is not in a known list |
Minimal example — a “checker” style
This style renders a two-colour checkerboard based on the seed.
import type { AvatarOptions, AvatarResult, Style } from "@avatar-generator/core";import { buildSvg, createRandom, DEFAULT_COLORS } from "@avatar-generator/core";
interface CheckerOptions extends AvatarOptions { gridSize?: number;}
export const checker: Style<CheckerOptions> = { name: "checker",
create(options: CheckerOptions): AvatarResult { const size = options.size ?? 64; const gridSize = options.gridSize ?? 4; const random = createRandom(options.seed); const palette = options.colors ?? DEFAULT_COLORS; const a = random.pick(palette); const b = random.pick(palette.filter((c) => c !== a));
const cell = size / gridSize; let content = ""; for (let row = 0; row < gridSize; row++) { for (let col = 0; col < gridSize; col++) { const fill = (row + col) % 2 === 0 ? a : b; content += `<rect x="${col * cell}" y="${row * cell}" width="${cell}" height="${cell}" fill="${fill}"/>`; } }
return buildSvg(content, options, a); },};Using it:
import { createAvatar } from "@avatar-generator/core";import { checker } from "./checker";
const avatar = createAvatar(checker, { seed: "Hugo GB", gridSize: 6 });document.querySelector("img")!.src = avatar.toDataUri();Literal union options + runtime validation
For categorical options, use a literal union type and the validateOption
helper so both compile-time and runtime reject invalid values.
import { buildSvg, createRandom, DEFAULT_COLORS, validateOption } from "@avatar-generator/core";
type CheckerPattern = "classic" | "diagonal" | "dotted";
const PATTERNS: CheckerPattern[] = ["classic", "diagonal", "dotted"];
interface CheckerOptions extends AvatarOptions { pattern?: CheckerPattern; gridSize?: number;}
export const checker: Style<CheckerOptions> = { name: "checker", create(options) { validateOption("checker", "pattern", options.pattern, PATTERNS); const random = createRandom(options.seed); const pattern = options.pattern ?? random.pick(PATTERNS); // … render based on pattern },};
// Export the array so consumers can build UI pickers off it:export { PATTERNS };Determinism checklist
Every bundled style in this repo passes the same snapshot test: the same seed and options must produce byte-identical SVG. Things that break that:
Math.random()anywhere increate. UsecreateRandom(options.seed).Date.now(),crypto.randomUUID(), or any other clock/entropy source.Array.sort()without a comparator on strings with case differences (sort is stable in modern engines, but compare explicitly to be sure).- Generating unique DOM IDs. If you need an id inside the SVG, derive it
from the seed rather than from a counter or a random value — that is how
core keeps its clip-path ids stable.
buildSvgalready handles this for the wrapper, so you only need it for ids you introduce yourself, such as a<linearGradient>you reference byurl(#…).
The repository’s own styles are covered by a snapshot test that renders each one from a fixed set of seeds. If you vendor a style into a project, the cheapest equivalent is asserting that two calls with the same seed are equal.
Packaging your style
To publish your style as a reusable npm package, follow the structure of
@avatar-generator/style-initials:
{ "name": "@your-scope/style-checker", "version": "1.0.0", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } }, "./package.json": "./package.json" }, "files": ["dist", "LICENSE", "README.md"], "sideEffects": false, "engines": { "node": ">=18" }, "scripts": { "build": "tsup src/index.ts --format esm,cjs --dts --sourcemap --clean --target es2020" }, "dependencies": { "@avatar-generator/core": "^3.0.0" }}Key choices, each of which the 2.x releases got wrong and 3.0.0 fixed:
"type": "module"plus a conditionalexportsmap. Plaintscoutput emits ESM with extensionless relative specifiers, whichimportrejects withERR_MODULE_NOT_FOUNDandrequirerejects on theexportkeyword. Only a bundler doing node10-style resolution can load such a package — so it will work in your dev app and fail for whoever installs it. Build with a bundler like tsup that emits real extensions and both formats.files, so the tarball carriesdist/and notsrc/and tsconfigs.sideEffects: false, to stay tree-shakeable.- Type declarations per condition (
.d.tsand.d.cts), so TypeScript resolves your option types under bothimportandrequire. @avatar-generator/coreas a dependency, matching the bundled styles. Core is stateless — no singletons, no registry — so a duplicated copy costs bundle size but never correctness. Make it apeerDependencyinstead if you would rather force a single copy and let the consumer own the version.
Verify before publishing rather than after. npx publint checks the manifest
and npx @arethetypeswrong/cli --pack . checks that your types resolve under
every condition; both are what this repository runs in CI.
Once published, users install both packages and use your style exactly like the bundled ones:
pnpm add @avatar-generator/core @your-scope/style-checkerimport { createAvatar } from "@avatar-generator/core";import { checker } from "@your-scope/style-checker";
createAvatar(checker, { seed: "user-42", gridSize: 8 });