blobatar
uipresence-avatarsource: ok · hit
Presence avatar
A blobatar with a presence dot and an unread badge. Online, away, offline, and a thinking state, on a face that blinks and breathes.
npx shadcn add "https://blobatar.dev/r/presence-avatar.json"free
Where it goes
stats
Interaction
animated-counter
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightblobatar@blobatar/react
Files1 · source cached 10/15/2026, 11:31:49 AM
registry/presence-avatar.tsx
/**
* A blobatar that also says how its person is doing: online, away, offline,
* thinking, and how many messages are waiting.
*
* ## Why this is a registry item rather than a prop on the adapter
*
* The dot and the badge are chrome around an avatar, not part of one. They are
* positioned against the avatar's box, they carry an application's vocabulary
* for presence, and every product spells that vocabulary differently: a support
* tool has "away", an agent runner has "thinking", a game has neither. Baking
* any of it into `@blobatar/react` would ship a states enum to everyone who
* only wanted a face. So the generator stays in the package and the composition
* gets copied into your project, which is the part you will want to edit.
*
* ## Every face here is alive, and that costs something
*
* `animate="always"` on all of them, not just on the one that is thinking. A
* presence avatar stands for a person who is *there*, and a row of faces that
* blink and breathe says that before any dot does. It also means the thinking
* state is a change of expression on a creature already moving, rather than the
* only thing on screen that moves.
*
* The cost is real and worth stating. A static blobatar renders as one `<img>`;
* an animated one cannot, because content inside an `<img>` is an isolated
* document where no host stylesheet can reach the shapes. So `animate` switches
* the adapter to inline SVG, roughly a dozen nodes per face. That is nothing for
* a sidebar of eight agents and wrong for a directory of four hundred people: at
* that size drop the prop, or use a plain `<Blobatar>` the way `user-table` does.
* The motion itself is CSS on seeded offsets, so it is the node count that
* scales, not a timer per avatar.
*
* Animation needs the library's stylesheet, imported once, anywhere in your
* app: `import "blobatar/motion.css"`. Without it every face here is still
* correct, it just holds still.
*/
"use client";
import { Blobatar } from "@blobatar/react";
import type { BlobatarOptions } from "blobatar";
import { thinking } from "blobatar/expression";
import { cn } from "@/lib/utils";
/**
* Four states, and no `busy`.
*
* `busy` was here and is gone on purpose. Against `thinking` it was the same
* claim in a different colour — both mean "this person or agent is occupied
* right now" — and a status vocabulary whose two middle values need a legend
* to tell apart is a vocabulary with one value too many. What is left is three
* facts about availability and one about activity, which is a distinction
* somebody can actually read off a badge.
*/
export type PresenceState = "online" | "away" | "offline" | "thinking";
/**
* What each state looks like and what it is called.
*
* The label is not decoration. A coloured dot is invisible to a screen reader
* and ambiguous to anyone who does not already know the convention, so every
* state carries the word as well and the word is what goes into the accessible
* name below.
*
* `thinking` has no dot: it renders the three-dot indicator instead. It is the
* one state that is about something in progress rather than about where
* somebody is, and a loading indicator says that in a way no colour does.
*/
const PRESENCE: Record<PresenceState, { dot?: string; label: string }> = {
online: { dot: "bg-emerald-500", label: "online" },
away: { dot: "bg-amber-500", label: "away" },
offline: { dot: "bg-zinc-600", label: "offline" },
thinking: { label: "thinking" },
};
/**
* How far past its own box the face is drawn. See the note in the component.
*
* A transform rather than a bigger element, so the layout box is untouched and
* nothing around it moves. It is also free of resolution cost: the picture is
* an SVG either way.
*/
const OVERFLOW = "scale-[1.18]";
export type PresenceAvatarProps = {
/**
* Who this is for. A username, a display name, an email, an agent's handle,
* a row id: any string, and the same string always renders the same face.
*/
name: string;
/** Defaults to `online`. `thinking` is the one that animates. */
state?: PresenceState;
/** Unread count. `0` and `undefined` both render nothing. */
unread?: number;
/**
* What to call this person in the accessible name, when `name` is an id or
* an email rather than something worth reading aloud.
*/
label?: string;
/**
* Anything the generator takes: `hue`, `background`, `traits`, and the rest.
*
* Nested rather than spread onto this component, so that a prop added to the
* library later cannot collide with a prop added here. It is also the seam
* for a house style: `{ traits: { shape: [0.11, 0.54, 0.99] } }` keeps every
* face in your product to three silhouettes while leaving colour, eyes and
* everything else per name.
*/
blobatar?: BlobatarOptions;
className?: string;
};
export function PresenceAvatar({
name,
state = "online",
unread,
label,
blobatar,
className,
}: PresenceAvatarProps) {
const presence = PRESENCE[state];
const who = label ?? name;
/*
* No `background`, so what renders is the silhouette itself on whatever the
* page is. A blobatar's body *is* the picture: a disc behind it turns ten
* distinct outlines into ten circles with different fills, which throws away
* the half of the identity you can read at 24px and across the room. Pass
* `blobatar={{ background: "circle" }}` where the layout needs a disc, which
* is mainly overlapping stacks, where two bare silhouettes would merge into
* one shape.
*
* That is also why the badges below sit at the corners of the box rather than
* on its edge: with no disc there is no edge, and a corner is the one place
* on a bounding box that no silhouette reaches into.
*
* And it is why the face is drawn slightly larger than its own box. A
* blobatar keeps a margin inside its viewBox so that no silhouette can touch
* the frame, which is right for the picture and wrong for anything pinned to
* a corner of it: the badge ends up floating in a gap with nothing under it,
* reading as a separate object rather than as a mark on this face. Scaling
* past the box closes the gap without changing the box, so a row of these
* still lines up on the layout size rather than on how wide each creature
* happens to be.
*
* The two branches exist because the adapter's props are a union: `animate`
* turns the element from an `<img>` into an `<svg>`, and passing
* `animate={undefined}` to satisfy both arms is exactly the thing the union
* is shaped to refuse.
*/
const face = (
<Blobatar
name={name}
{...blobatar}
/*
* One element for every state now, where this used to be two. The branch
* existed because `animate` decides whether the adapter renders an `<img>`
* or an `<svg>`, and the props union follows the element: passing
* `animate={undefined}` satisfies neither arm. With the prop constant
* there is nothing left to branch on, and the expression rides on top.
*/
animate="always"
expression={state === "thinking" ? thinking : blobatar?.expression}
// Decorative: the accessible name is on the wrapper, and a picture that
// announced itself here would be read twice.
aria-hidden="true"
className={cn("size-full", OVERFLOW)}
/>
);
return (
/*
* One labelled node, not three. The face, the dot and the badge are one
* fact about one person, and a screen reader that met them separately would
* read "image, status, 3" with nothing tying the three together.
*/
<span
className={cn("relative inline-block size-10 shrink-0", className)}
role="img"
aria-label={
unread
? `${who}, ${presence.label}, ${unread} unread`
: `${who}, ${presence.label}`
}
>
{face}
{/*
Top right, which is where a status light goes when the thing under it is
not a rectangle. On a disc the conventional spot is bottom right, on the
curve; on a bare silhouette that corner is under the widest part of most
bodies, and a `sun` or a `cloud` puts a lobe straight through it.
Sized as a percentage of the avatar rather than in pixels, which is the
only version of this that survives being used at more than one size. A
`size-3` dot is a quarter of a 48px face and a third of a 32px one: the
same component reads as an avatar with a status light in one list and as
a status light with an avatar behind it in the next.
`ring-background` rather than a border: the ring is the page showing
through, which separates the dot from whatever it overlaps without
introducing a colour that has to be chosen per surface.
*/}
{state === "thinking" ? (
<span
aria-hidden="true"
/*
* Under the face and centred, not in a corner, because it is not the
* same kind of mark as the others. A presence dot is a property of
* the person and belongs on them; this is a process running, and
* underneath is where a caption goes. Centred also means it does not
* have to dodge whichever silhouette it lands on.
*/
className="absolute -bottom-1 left-1/2 flex -translate-x-1/2 items-center gap-[3px]"
>
{/*
Three dots rather than one pulsing light, because this is the only
state that means "wait" and a loading indicator is the shape
everybody already reads as waiting.
The stagger is an inline style rather than a class, and that is a
Tailwind fact rather than a preference: utilities are compiled from
the class strings found in the source, so a delay interpolated into
a class name is a class that was never generated. Duration is set
here too, since `animate-pulse` runs at 2s, which reads as breathing
rather than as loading.
*/}
{[0, 1, 2].map(i => (
<i
key={i}
/* `muted-foreground`, the colour of everything on a page that is
present but not being asserted. A blue light says "look here",
and a thing that is merely still working should not. */
className="bg-muted-foreground size-1 animate-pulse rounded-full"
style={{ animationDuration: "1.1s", animationDelay: `${i * 150}ms` }}
/>
))}
</span>
) : (
<span
aria-hidden="true"
className={cn(
"ring-background absolute top-0 right-0 h-[26%] w-[26%] rounded-full ring-2",
presence.dot,
)}
/>
)}
{/*
The count takes the other corner, since the dot has this one now. They
are two different questions anyway: the dot is about the person, the
badge is about your inbox, and stacking them would make a 99+ pill
swallow the state it is sitting on.
*/}
{unread ? (
<span
aria-hidden="true"
className="bg-primary text-primary-foreground ring-background absolute -right-1 -bottom-1 grid h-5 min-w-5 place-items-center rounded-full px-1 font-mono text-[0.65rem] leading-none ring-2"
>
{/* Past two digits the badge stops being a count and starts being a
layout problem. Anything over 99 is "lots", which is all the
number was ever telling you. */}
{unread > 99 ? "99+" : unread}
</span>
) : null}
</span>
);
}