Skip to content

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:

HelperWhat 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_COLORSVivid 10-color palette used as a fallback
SKIN_TONES, EYE_COLORSCurated 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 in create. Use createRandom(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. buildSvg already handles this for the wrapper, so you only need it for ids you introduce yourself, such as a <linearGradient> you reference by url(#…).

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 conditional exports map. Plain tsc output emits ESM with extensionless relative specifiers, which import rejects with ERR_MODULE_NOT_FOUND and require rejects on the export keyword. 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 carries dist/ and not src/ and tsconfigs.
  • sideEffects: false, to stay tree-shakeable.
  • Type declarations per condition (.d.ts and .d.cts), so TypeScript resolves your option types under both import and require.
  • @avatar-generator/core as 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 a peerDependency instead 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:

Terminal window
pnpm add @avatar-generator/core @your-scope/style-checker
import { createAvatar } from "@avatar-generator/core";
import { checker } from "@your-scope/style-checker";
createAvatar(checker, { seed: "user-42", gridSize: 8 });