@nomudev/blob
v0.3.5
Published
Le poisson de Nomu, anime : un avatar SVG pilote par un etat et une expression.
Readme
@nomudev/blob
Nomu's blob, animated, in any React page.
An <svg> with no background, driven by one prop: what the assistant is doing. No
dependency, no stylesheet, no image to load — the drawing is computed, 16 kB gzip once and
for all.
import { NomuBlob } from '@nomudev/blob/react'
<NomuBlob mood="thinking" size={40} />Install
pnpm add @nomudev/blobThat is all of it. No token, no registry to configure, nothing special in CI or on Vercel.
React 18 or 19, as a peerDependency — the package installs nothing.
The component
| prop | default | what it is |
|---|---|---|
| mood | 'idle' | what the assistant is doing, see the table below |
| expression | 'neutre' | the face's mood — only visible on idle |
| size | 96 | side of the square, in pixels |
| follow | false | the gaze follows the pointer |
| surprise | true | clicking the bot brings the fish back for a second |
| label | 'Nomu' | name for screen readers; empty string = decorative |
| frozenAt | — | freezes the render at that date: a thumbnail, no loop |
| className, style | — | applied to the container |
There is no colour prop: the blob wears its own skin — the gradient, the warm blot and the white rim of the Nomu drawing — and it is an identity, not a palette.
Changing a prop does not remount the component: the clock keeps running, and the blob
crosses from one state to the next through the fade measured off the video. That matters
most for mood, which changes on every message.
The moods
Seven are held for as long as you leave them:
| mood | what you see |
|---|---|
| idle | at rest: breathes, blinks, follows the cursor |
| listening | the eyes open wide |
| thinking | the body becomes three pulsing dots |
| working | the blob turns, the six measured rings orbit it |
| searching | the blob scans, no rings |
| sleeping | asleep |
| warning | the "!", held upright |
Five are gestures: they play once, then the blob returns to the held state. A notification that stays on screen is no longer a notification.
| mood | what you see |
|---|---|
| notification | the blue dot appears |
| alert | the "!" travels across and comes back |
| wink | a wink |
| sending | the blob tightens, the ribbons orbit it, it comes back |
| celebrating | the blob collapses, particles fly out, it recomposes |
To fire a gesture without going through the prop — the normal case, since a gesture is not a state:
const blob = useRef<BlobHandle>(null)
// ...
<NomuBlob ref={blob} mood={busy ? 'thinking' : 'idle'} />
blob.current?.play('notification')The fourteen catalogue ids (egg, hexagon, play, burst…) are also accepted in
mood, for whoever knows what they want.
The expressions
Sixteen, and they only show on idle: everywhere else the face is part of the animation
measured off the video, and overwriting it would mean no longer playing it.
neutre, attentif, surpris, excite, heureux, hilare, colere, triste,
effraye, mefiant, confus, curieux, fier, timide, blase, somnolent.
<NomuBlob mood="idle" expression="heureux" />In a chat
The typical wiring, driven by the conversation's state:
'use client'
import { NomuBlob, type BlobHandle } from '@nomudev/blob/react'
import type { BlobMood } from '@nomudev/blob'
function moodOf(status: Status): BlobMood {
if (status === 'tool') return 'working'
if (status === 'thinking' || status === 'streaming') return 'thinking'
if (status === 'error') return 'warning'
return 'idle'
}
export function NomuAvatar({ status }: { status: Status }) {
const blob = useRef<BlobHandle>(null)
useEffect(() => {
if (status === 'done') blob.current?.play('wink')
}, [status])
return <NomuBlob ref={blob} mood={moodOf(status)} size={36} follow />
}Two habits:
- one instance, kept mounted, whose
moodyou change. Mounting one blob per message restarts the clock every time, and the fades never happen; followon the big one, not on thumbnails: it listens to the pointer across the whole window.
Two entry points, and why
@nomudev/blob/react carries 'use client'. React therefore replaces every one of its
exports with a client reference: a server component reading one gets an opaque proxy,
not the value. So the catalogues live in @nomudev/blob, which imports from either side.
import { MOODS, EXPRESSIONS, PEAU } from '@nomudev/blob' // values, anywhere
import { NomuBlob, type BlobHandle } from '@nomudev/blob/react' // the componentWritten the other way round, Object.entries(MOODS) in a page returns an empty list —
with no error and no warning.
The component's container is a <span> carrying data-nomu-blob. A descendant selector
in your page can hit it without meaning to (.card > span { opacity: .6 } and the blob
goes pale): the attribute is there to target it, and above all to exclude it.
Without React
The main entry point depends on nothing:
import { createBlob } from '@nomudev/blob'
const blob = createBlob(document.querySelector('#avatar')!, { mood: 'thinking' })
blob.update({ mood: 'idle' })
blob.play('notification')
blob.destroy()That is exactly what the React binding drives — and what the Vue studio uses too. There is only one drawing.
Clicking brings the fish back
The body was Nomu's fish before it was a rounded square, and clicking the bot returns it for two and a half seconds — wearing today's gradient and rim, so it arrives as a variation rather than as another application blinking through.
Nothing to wire: it is on by default. The click is never captured, so a page that
listens for its own still receives it. surprise={false} turns it off for a host that wants
the click to itself.
It is deliberately not exposed as a button — no role, no focus ring, no cursor change. It opens nothing and leads nowhere, and a gag that announces itself isn't one.
The background
There isn't one. The eyes are real holes punched in the body, not light shapes laid on top: that is what makes them clip themselves when they slide towards the edge of the silhouette. So they show the page through, whatever it is.
The paper option puts a plate of the body's exact shape underneath, which fills the eyes
with a given colour. It is only for a render with nothing behind it — a PNG export, a
rasterised thumbnail.
The body stays Nomu
working, searching, sending and celebrating play the animations measured off the
video — orbit's rings, the comet's ribbons, the burst's particles — but it is the blob
playing them, not the ball. The originals don't tuck the body away, they erase it: a
spinning triangle, a dot, dust. That is right in a montage a few seconds long; in a
conversation, the avatar is an identity.
The one thing a chosen body doesn't take is orbit's triangle: that is a shape, not a
scale. The rotation is there — applied to the whole avatar, face included, rather than to
the profile alone. The white rim follows the body's own size, so a body folded into a dot
doesn't wear a ring thicker than itself. The fourteen measured states are untouched and
still reachable by their id.
What isn't a setting
The bot's numeric constants are measurements taken frame by frame off a reference video, not settings: gaze angles, eye sizes, timings. The body and its colours are Nomu's blob, read off the original drawing. Rounding them or "simplifying" them breaks the resemblance, which is the only success criterion here.
The repository explains the detail, in docs/measurements.md and docs/architecture.md.
