blobatar for developers
Everything blobatar.dev serves that is meant to be called rather than read: the avatar endpoint, its OpenAPI description, the packages, and the terms — no key, no account, no rate limit to negotiate.
When to use it
Reach for blobatar when an application needs a picture of somebody it has no picture of: a user who has not uploaded an avatar, a commit author, a bot, a team, a repository, a seat in a list. It turns any string into a stable geometric face, so the same handle is the same creature everywhere it appears, with nothing stored anywhere.
Use the HTTP endpoint when the avatar has to be a URL — an <img src>, an email, a Slack or GitHub profile field, an OG image, anything rendered by software you do not control. Use the packages when you are rendering in an app you do own, since generating in-process costs no request and no network. Both produce the same blobatar for the same name.
It is a poor fit for two things. It is not an identicon-compatible drop-in — the shapes are its own, so switching from another generator changes every existing avatar. And it is not an image host: there is no upload, and nothing you send is kept.
The HTTP endpoint
One route, no authentication, and every response is safe to hotlink and to cache.
GET https://blobatar.dev/avatar/<name>
A name is anything that stands for somebody — a username, a display name, an email, an id, a Gravatar digest. Names are NFC-normalized, trimmed and lowercased before hashing, so /avatar/Alain and /avatar/alain are one blobatar reached by two URLs; prefer one spelling, because every cache in the path treats them as two. A name containing a slash must be percent-encoded as %2F.
curl -s "https://blobatar.dev/avatar/alain%40example.com?size=64" > blobatar.svg <img src="https://blobatar.dev/avatar/alain?size=48&background=squircle" alt="" />
Parameters
All optional, and named as the library names them.
| parameter | accepts | notes |
|---|---|---|
| name | string, ≤ 256 chars | Anything that stands for somebody: a username, an email, an id, a Gravatar hash. 256 characters or fewer after percent-decoding. A name containing a slash must be percent-encoded as %2F. |
| size | 8–1024 | Pixel size of the rendered SVG, 8–1024. Clamped into range rather than rejected, because a blobatar at the wrong scale is fixable with CSS and a 400 is a broken image. Omit to let the consumer size it. |
| s | 8–1024 | Gravatar's spelling of `size`, accepted so that moving an integration here is a host edit. Wins if both are present. |
| background | none · square · circle · squircle | Shape drawn behind the body. Omit or pass `none` for a transparent backdrop, which is the default — the body is the blobatar. |
| hue | 0–360 | Locks the colour in degrees, 0–360, so the name drives shape only. 360 is accepted alongside 0: hue is a circle and callers compute into it. |
| tone | 0–1 | Locks the swatch as a 0–1 position in the tone set, pale to ink. The bands are half-open, so an exact 1 sits on the top edge and renders as 0 — pass 0.999 for ink. |
| expression | idle · happy · sad · mad · surprised · wink · sleepy · smug · unsure · scared · love · shy · sick · thinking | A pose the blobatar holds. Decorative: it never adds a mark, so it does not reach assistive technology and does not change the accessible name. |
| title | string, ≤ 128 chars | Accessible name, 128 characters or fewer. Emitted as a <title> inside the SVG. Names who the blobatar stands for, not what it looks like. |
| gen | 1 · 2 | Pins the shape vocabulary. A generation is one frozen name-to-blobatar mapping and is never retired, so a pinned URL cannot come back different — which is why pinned responses are cached for a year as immutable. Unpinned follows the current major. |
Replacing Gravatar
Swap the host and keep the rest of the URL. Gravatar's own parameters are accepted so that the move is a host edit and nothing more; the ones that select a fallback image do nothing here, since every string renders and there is no missing avatar to fall back to. Code using d=404 to detect "this person has no Gravatar" gets a 200 instead.
| parameter | accepts | notes |
|---|---|---|
| d | string | Accepted for drop-in Gravatar compatibility and ignored: every string renders, so there is no missing avatar to fall back to and nothing above a G rating to filter. Do not send it in new code. |
| default | string | Accepted for drop-in Gravatar compatibility and ignored: every string renders, so there is no missing avatar to fall back to and nothing above a G rating to filter. Do not send it in new code. |
| f | string | Accepted for drop-in Gravatar compatibility and ignored: every string renders, so there is no missing avatar to fall back to and nothing above a G rating to filter. Do not send it in new code. |
| forcedefault | string | Accepted for drop-in Gravatar compatibility and ignored: every string renders, so there is no missing avatar to fall back to and nothing above a G rating to filter. Do not send it in new code. |
| r | string | Accepted for drop-in Gravatar compatibility and ignored: every string renders, so there is no missing avatar to fall back to and nothing above a G rating to filter. Do not send it in new code. |
| rating | string | Accepted for drop-in Gravatar compatibility and ignored: every string renders, so there is no missing avatar to fall back to and nothing above a G rating to filter. Do not send it in new code. |
Caching and generations
An unpinned URL is cached for a day and served stale for a month while it revalidates. A URL that pins a generation with ?gen= is cached for a year as immutable, because a generation is one frozen name-to-blobatar mapping and is never retired — it cannot come back different. Responses carry an ETag you can revalidate with If-None-Match.
New silhouettes arrive as a new generation rather than as a change to yours, so pin one if a rendered avatar must never change.
Errors
A rejected request answers in plain text, which is what an <img> tag and a terminal want. Ask for JSON and the same error arrives as a structured body with a stable code to branch on and a hint naming the fix. The full list of codes is enumerated in the OpenAPI spec.
$ curl -s -H "Accept: application/json" "https://blobatar.dev/avatar/alain?expresion=happy"
{
"error": {
"code": "unknown_parameter",
"message": "unknown parameter \"expresion\" — expected one of s, size, …",
"hint": "Remove the parameter or correct its spelling.",
"status": 400,
"documentation": "https://blobatar.dev/docs"
}
}Authentication and limits
There is no API key, no account and no per-caller quota. The endpoint is a pure function of its URL served from Cloudflare's edge, so the useful thing you can do for both of us is let the cache work: send If-None-Match, keep one spelling per name, and pin a generation when you can. If you expect sustained heavy traffic, render in-process with the packages or deploy your own copy — both are the same code and neither needs us.
Packages
Zero dependencies, ESM, typed. The core renders a string to SVG markup; the framework packages are thin components over it and pin the core to an exact major, because the two are one release.
bun add blobatar # the generator, ~4.4 KB gzipped bun add @blobatar/react # also /vue, /svelte, /solid, /preact, /react-native bunx @blobatar/cli alain # write one to a file
import { Blobatar } from "@blobatar/react";
<Blobatar name={user.email} size={48} />;There is a shadcn registry too — it installs a wrapper around shadcn's Avatar that falls back to a blobatar when a user has no profile image.
npx shadcn@latest registry add @blobatar=https://blobatar.dev/r/{name}.json
npx shadcn@latest add @blobatar/avatarThe full API — traits, palettes, expressions, animation and what is guaranteed to stay stable across versions — is in the README, and in llms.txt if you are a machine.
Motion, and the eyes
Animation is opt-in and off by default. With blobatar/motion.css loaded and an animate prop set, a blobatar breathes, bobs, blinks and glances, all of it seeded from the name so that a grid reads as a crowd rather than a drill team. That layer is pure CSS: the browser runs it and nothing in JavaScript is involved.
One layer is not, and cannot be. A gaze is a function of where the pointer is, which no keyframe can know, so it ships as its own entry point and its own stylesheet — a page that never imports them pays nothing for it.
import { useGaze } from "@blobatar/react/gaze";
import "blobatar/gaze.css";
const { ref, lookAt } = useGaze({ travel: 3, lookAt: "pointer" });
<Blobatar ref={ref} name={user.email} animate="always" size={200} />;
// the option is where it usually looks; the function is where it looks now
lookAt({ x, y }); // a point in client coordinates — a caret, a card
lookAt(el); // an element: its centre, re-read as the page moves
lookAt("pointer"); // the cursor
lookAt("rest"); // its own centre, held: deliberately not looking
lookAt(null); // nothing — the idle glance comes backA separate subpath, so it costs nothing unless you import it — the same bargain @blobatar/react-native/animated makes. Every adapter has one, under the shape its framework reaches an element with: a hook in React and Preact, a composable that takes your template ref in Vue, a ref that is the binding in Solid, an {@attach} in Svelte. Anywhere else, gaze(svgEl) from blobatar/gaze is the same driver without the binding.
travel is the excursion, and it is what opts a blobatar in. --mo-track-travel starts at 0px, so with the stylesheet loaded and the excursion set nowhere every face on the page holds still. It is in viewBox units — the blobatar is 100 across, so 3 is 3% of the face. Without a binding, or for a whole field of them at once, set the property instead: it inherits, and it can be made responsive.
.hero .mo-eyes { --mo-track-travel: 3px; }The idle glance stands down on its own while the gaze is driving, so the eyes are never being aimed at two things at once. Nothing attaches under prefers-reduced-motion or without a fine pointer, both are watched rather than sampled once, and a settled blobatar under a still pointer schedules no frames at all.
The blobatar at the top of this site is running it, and the password field is a worked example: it watches the caret while you type and looks away when you reveal what you typed.
Machine-readable
Three files, at fixed paths, none of which need JavaScript to read:
https://blobatar.dev/openapi.json the endpoint, as OpenAPI 3.1 https://blobatar.dev/llms.txt the library, as prose https://blobatar.dev/sitemap.xml every page here
The spec is generated from the endpoint's own parser, so its enums are the values the endpoint actually accepts rather than a list somebody remembered to update. Every operation carries a unique operationId and a description, which is what function-calling formats read.
Running your own
The endpoint is a single Cloudflare Worker with no bindings, no storage and nothing account-specific in its configuration, kept that way precisely so a fork deploys unchanged. Clone apps/api, deploy, and it answers on your own hostname — including its own /openapi.json, which names your origin rather than this one.
It is MIT, and so is everything else here. Questions and bugs go to contact.