react-ai-pet
v0.4.4
Published
Customisable animated pet avatar for React: shapes, colours, expressions, jelly physics and cursor-following eyes. A ray-marched WebGL body, zero dependencies.
Maintainers
Readme
react-ai-pet
A small, dependency-free animated pet avatar for React: a handful of props, and a ray-marched WebGL body with real refraction and lighting.
Live demo, docs and playground: https://ai-pet.yajis.dev
- 12 body shapes — circle, blob, squircle, capsule, triangle, hexagon, cloud, drop, star, poop (a soft-serve swirl), mochi (a rice cake: straight sides that round under into a soft base), daifuku (a squashed ball, gently rounded underneath) — that morph smoothly into each other
- 17 expressions — neutral, happy, excited, laughing, surprised, curious, confused, dizzy, sad, angry, scared, sleepy, suspicious, wink, shy, proud, unimpressed — that blend into each other
- Any colour (any CSS colour string), eye colour too, and
eyeShape="dot"for small round bead eyes instead of the tall capsules.eyeShineadds a catchlight,blushsoft pink cheeks, andeyessets their size, spacing and height. Addcolor2for a two-tone body that blends from one colour to the other, andiridescencefor a thin-film rainbow sheen that shifts with the viewing angle and shimmers as the body moves - WebGL renderer — the body is a real 3D solid rendered by ray marching a signed distance field: lit like a small studio (a warm key light, a cool fill, sky above and the page's own colour bouncing up from below), GGX highlights per material, self-shadowing and contact occlusion, Fresnel reflections, refraction into a translucent jelly with Beer-Lambert absorption, eyes embedded inside it, a soft sky-tinted contact shadow, and a filmic tone map. Matte bodies get a clean clay look. Zero dependencies, one fragment shader.
- Four materials —
matte(opaque clay),jelly(translucent),frost(milky and frosted: soft highlights, no see-through, and it glows where it is thin) andglass(clear and rigid: higher refractive index, sharp highlights, a second reflection off the far surface that lights up the edges). Every material is bouncy by default: a spring-based squash, wobble and lean plays when the avatar is poked, changes shape, or the pointer darts around (bouncy={false}turns it off). - Follows the cursor — the whole body turns towards the pointer (a real rotation of the body) and the eyes move a little with it; the eyes live on a sphere and foreshorten as they near the edge
- Reactions — performances you can fire:
alert(!),question(?),thinking(…),ponder(aurora ribbons swirling while it thinks),orbit(glowing orbs on atomic orbits while the body spins round twice),love(hearts),sparkle(stars),magic(a spiral of sparkles),celebrate(confetti),charge(winds up, then rings and sparks burst out),eyeBeam(whole eyes flicker to charge, then fire broad yellow-to-gold beams with a bright white core),shocked(freezes in comic shock with blue stress lines),idea(a light bulb pops up top right and flicks on),melt(flattens into a sleepy puddle),giggle(soft chuckles and a small hop),nuzzle(leans toward the cursor with a floating heart),nod,shake,sleepy(zzz),gloom(a private rain cloud),fume(steaming with anger),sweat(a cold sweat: darting eyes and a bead running down the cheek). The body hops, tilts or shakes, the face changes for a moment, and the effects are drawn in an SVG layer over the canvas.thinking,ponder,orbit,gloom,sweatandsleepycan be held - Idle life: gaze drift, breathing, a deterministic blink schedule
- Honours
prefers-reduced-motion - SSR-safe, no
Date.now()or randomness in the frame: the engine is a function of time
Install
npm i react-ai-petUse
import { PetAvatar } from 'react-ai-pet'
<PetAvatar shape="blob" color="#3b82f6" expression="happy" material="jelly" size={240} />
<PetAvatar shape="drop" color="#1fb6c1" expression="surprised" material="glass" />
<PetAvatar shape="blob" color="#22d3ee" color2="#7c3aed" iridescence={0.6} material="glass" />Props
| prop | type | default | |
|---|---|---|---|
| shape | ShapeId | 'circle' | body shape |
| color | string | '#0b0b10' | any CSS colour |
| color2 | string | | second colour: the body blends from color (top-left) to color2 (bottom-right) |
| iridescence | number | 0 | thin-film rainbow sheen at grazing angles, 0 to 1 |
| glow | number | 0 | the body lights itself in its own colour, 0 to 1, whatever the lamps or the page do; it lights the ground under it too |
| halo | number \| { strength?, color?, size? } | none | a soft glow on the page behind the body: a number is the strength 0 to 1 in the body's colour; the object also sets the colour and how far it reaches (in multiples of the frame, default 1) |
| eyeColor | string | '#ffffff' | |
| eyeShape | 'capsule' \| 'dot' | 'capsule' | dot is a pair of small round beads; every expression still applies |
| eyeShine | number | 0 | a small catchlight high in each eye, 0 to 1 |
| eyeGlow | number | 0 | the eyes light up in their own colour and cast it on the body around them, 0 to 1 |
| blush | number \| { strength?, color? } | none | soft cheeks under the eyes: a number is the strength 0 to 1 in a rosy pink; the object also sets the colour |
| eyes | { size?, spacing?, height? } | | size and spacing as multiples of the default (1), height -1 (low) to 1 (high); changes ease in, expressions apply on top |
| expression | ExpressionId | 'neutral' | |
| material | 'matte' \| 'jelly' \| 'glass' \| 'frost' | 'matte' | body material; jelly (boolean) is a shorthand for material="jelly" |
| bouncy | boolean | true | spring physics (squash, wobble, lean) on pokes, shape changes and fast pointer moves; false for a stiff body |
| translucency | number | jelly and frost 0.5, glass 0.7 | how much light passes through a jelly, frost or glass body, 0 (opaque) to 1 (clear) |
| inner | InnerPattern \| { pattern, color?, color2?, strength?, scale?, motion?, hueShift?, detail? } | none | what a jelly, glass or frost body holds inside, lit from within and seen through the surface. Patterns: orb (a few large soft veils of colour drifting through the dark), nebula (two colours of cloud with stars behind), swirl (smoke folding slowly), marble (fumed glass, a few thin luminous sheets folded and layered in the dark, faint face-on and bright along their edges, with glitter caught in the folds), sparkle (twinkling flecks), fireflies (motes wandering and blinking); color defaults to the body's hue at full brightness, color2 is the swirl's gaps, the nebula's other cloud, the orb's second wisp and the marble's second sheet, whose third lies between (default the first colour), strength 0 to 1 (0.7), scale a multiple of the default size (1), motion 0 to 1 (0.3), hueShift makes the second colour instead of color2: the first turned this many degrees round the hue wheel (180 the complement), default 0; detail 0 to 1 (0.5) is how finely the marble's sheets are drawn, thinner and crisper for more GPU |
| backdrop | string | | a solid colour rendered behind the avatar for the jelly to refract; omit to let the page show through |
| lighting | preset name or Lighting | 'studio' | up to four lights (direction, colour, intensity, size, softness, shadow), ambient, roughness, reflection, shadow strength / softness / tint, contact darkening, exposure. Presets: studio, noon, dramatic, soft, flat (quiet and even, no shadow) |
| background | string | detected | the page colour behind the avatar, used to light and composite translucent bodies; read from the DOM by default, pass a CSS colour to override |
| followCursor | boolean | true | eyes follow the mouse or pen anywhere in the window; a finger on a touch screen is not followed |
| followReach | number | 1.6 | distance (in avatar widths) at which the gaze saturates |
| size | number \| string | 240 | px, or any CSS length. The WebGL canvas is drawn 1.5× wider than this (centred, pointer-transparent) so the ground shadow is not cut off; keep overflow: hidden off the parent |
| paused | boolean | false | freeze the animation. An idle avatar also stops painting on its own while it is scrolled out of view (reactions, entrances and exits still play), so a page full of avatars only pays for the ones on screen |
| reducedMotion | boolean | OS setting | |
| pokeOnClick | boolean | true | tap the body to bounce; the empty frame around it is click-through to the page beneath, and anything in front of the body that takes pointer events gets the tap instead |
| onPoke | () => void | | |
| enter | 'pop' \| 'roll' \| 'rollBack' \| 'drop' \| 'fade' \| 'summon' \| 'walk' | | entrance, played on mount and whenever visible turns true; summon draws a magic circle on the ground and materialises the body in a column of light |
| exit | same | enter | exit, played when visible turns false |
| visible | boolean | true | show / hide, animated when enter / exit are set |
| onEnterEnd, onExitEnd | () => void | | |
| stage | RefObject<Element> \| 'viewport' | | what entrances and exits travel from and to: a container's edge or the viewport's; default just past the avatar's own box |
| reaction | ReactionId \| null | | plays a reaction whenever it changes to an id |
| onReactionEnd | (id) => void | | |
| effectColor | string | currentColor | colour of reaction marks and dots |
| effectStyle | 'glass' \| 'solid' | 'glass' | marks and dots as translucent frosted glass, or flat fills |
| speech | string \| Speech \| null | | a speech bubble beside the body, see Speech |
| onSpeechEnd | () => void | | fires when a timed speech has been taken down |
| gauge | number \| Gauge \| null | | a 0–100 progress gauge, poured into the body or ringed around it in light, see Gauge |
| className, style, title | | | passed to the wrapper element (title labels the canvas) |
The body is drawn on a WebGL2 canvas; where WebGL2 is unavailable the frame stays empty, and the
reactions, speech bubble and gauge ring (HTML and SVG overlays) still show. The canvas is
transparent and premultiplied, so it composites onto any page background. The renderer
reads the page colour behind it (or takes the background prop) and adapts: on a dark page the
studio's ambient light goes away so glass shows as rims and highlights rather than a haze, the
transmitted colour is composited against the real background, and the ground shadow becomes the
faint light a translucent body lets through.
Lighting
<PetAvatar lighting="dramatic" />
<PetAvatar
lighting={{
lights: [
{ direction: [-0.6, 0.8, 0.5], color: '#fff1dd', intensity: 1.2, size: 0.2, shadow: true },
{ direction: [0.8, 0.2, 0.4], color: '#cfe4ff', intensity: 0.4, size: 0.6 }
],
ambient: 0.8,
roughness: 0.3,
shadow: { strength: 1, softness: 0.3, tint: '#101828' },
exposure: 1.1
}}
/>Directions point towards the light (x right, y up, z towards the viewer). A light's size widens
its highlight and softens the shadows it casts; softness is how soft the lamp's edge is where it
reflects in the body (0 a crisp soft box, as a glass ball shows it, 1 a hazy glow; left out, matte
and jelly are hazy and glass is crisp); shadow: true makes it cast the ground shadow and
self-shadowing (the first light does by default). Anything you leave out keeps the studio default,
so { ambient: 0.5 } alone just dims the room.
Entrances and exits
const [open, setOpen] = useState(true)
<PetAvatar enter="pop" exit="roll" visible={open} onExitEnd={() => console.log('gone')} />pop grows in with a boing and shrinks away, roll rolls in from the left and out to the
right (rollBack the other way), drop falls in and lands with a squash then jumps away,
walk takes small swaying steps in from the left and out to the right.
fade is the quiet one. With enter set the avatar mounts hidden and plays the entrance;
ref.current.enter(id?) / exit(id?) play them on demand (measuring the stage for you). Pass stage={containerRef} (or
"viewport") and the roll and drop travel from that container's edge instead of the avatar's own
box: the whole element is moved with a CSS transform, so nothing is clipped on the way.
Reactions
const ref = useRef<PetAvatarHandle>(null)
<PetAvatar ref={ref} />
ref.current?.avatar.react('alert') // one shot
ref.current?.avatar.react('ponder', { duration: Infinity }) // hold …
ref.current?.avatar.stopReaction() // … until you say soOr declaratively: <PetAvatar reaction="love" onReactionEnd={() => setReaction(null)} /> plays whenever the
prop changes to a new id. Marks and dots use the layer's CSS color (effectColor prop, default
currentColor), so they follow your page's text colour; hearts and stars have their own colours.
Reactions are defined in src/core/reactions.ts as pure functions of the time since they started, returning a
body pose (squash, offset, tilt, turn), a temporary expression and gaze, and a list of effects to draw.
Adding one is adding an entry to that table.
Speech
<PetAvatar speech="Hi there! 👋" />
<PetAvatar speech={{ text: 'Over here', placement: 'left', offset: { y: -10 }, typing: 60 }} />
ref.current?.say('Done!', { duration: 3000 }) // a one-off line, then back to the prop
ref.current?.say(null) // take it downThe bubble is plain HTML positioned against the avatar's box, with a tail pointing at the body, so it
may hang out over the page (keep overflow: hidden off the parents). It pops in, types its text
(typing characters per second, 0 for all at once) and pops out when cleared or when duration
runs out. placement is one of top, top-left, top-right, left, right, bottom,
bottom-left, bottom-right (default top-right); offset nudges it in pixels; maxWidth,
fontSize, background, color, className and style restyle it. The bubble rides on the body: it
sways with the idle drift, bounces with a poke and turns with the body towards the pointer;
follow: false keeps it still beside the frame. The bubble (and the gauge's ring) waits
until the avatar is on stage: it appears once an entrance has finished and goes away as an exit
starts, so a timed line set during an entrance starts its clock when it is shown.
Gauge
<PetAvatar gauge={progress} /> // liquid fills the body
<PetAvatar gauge={{ value: progress, style: 'ring', color: '#ff7ae6' }} /> // a ring of light around itvalue runs 0–100 and the drawn level eases after it, sloshing while it moves. 'fill' (default)
pours a liquid into the body: a real volume inside the ray-marched body, absorbing
and scattering light in a jelly or glass and painted with a meniscus on a matte one. 'ring' closes a glowing ring of light clockwise around
the body, with a spark at its head, pulsing once complete; label: true prints the
percentage under it.
Imperative control
const ref = useRef<PetAvatarHandle>(null)
<PetAvatar ref={ref} />
ref.current?.avatar.poke()
ref.current?.avatar.setLook({ x: 0.5, y: -0.2 }) // -1..1 each axis, null to release
ref.current?.say('Hello', { duration: 2000 }) // a speech bubble
ref.current?.element // the <canvas>Without React
The engine has no framework dependency: createAvatar gives you an object you tick with a clock, and createGlRenderer draws a frame onto any WebGL2 canvas.
import { createAvatar } from 'react-ai-pet/core'
import { createGlRenderer } from 'react-ai-pet'
const avatar = createAvatar({ shape: 'star', expression: 'excited' })
const renderer = createGlRenderer(canvas) // null where WebGL2 is unavailable
renderer?.resize(canvas.width, canvas.height)
const tick = (now: number) => {
renderer?.render(avatar.update(now), { color: '#f6c945', eyeColor: '#ffffff', material: 'jelly', translucency: 0.3 })
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)A still is canvas.toDataURL() after one render().
How it works
- Shapes are radial profiles. Every body is
r(θ)sampled at 96 angles, so morphing between any two shapes is a per-sample lerp of radii. Polygons, unions of circles and parametric curves are turned into profiles by ray casting and then smoothed to round the corners. All shapes are normalised to the same area so they carry the same visual weight. - Eyes sit on a sphere. Each eye has a longitude and latitude; the gaze rotates the sphere and the eye is projected orthographically, squashing as it turns away. The projected point is then scaled by the body's real radius in that direction so the face fits non-circular shapes.
- Expressions are eye specs: width, height, roundness, tilt, and two lids (in body colour, clipped to the eye) whose curvature ranges from a tight smiling arch to a flat line.
- Lids are cut out of the eye in the shader rather than painted over it, so a translucent body shows through a half-closed eye.
- Jelly motion is three damped springs: squash/stretch, a third-harmonic outline wobble, and a lean towards the pointer. Impulses come from pokes, shape and expression changes, and fast gaze movement.
- The WebGL renderer ray-marches an SDF. The 2D profile is inflated like a balloon: the CPU solves a Poisson pressure field inside the outline (
src/gl/inflate.ts, a 64×64 grid re-solved only when the profile changes) and the shader reads it as a texture, so the body is an exact ellipsoid for a circle, thinner in narrow lobes, and free of creases along dents in the outline; near the silhouette the radial ratiorho / r(θ)takes over, which is exact there. The face is a flat 2D drawing projected onto it (printed on a matte body, seen just under the surface of a jelly one), and each pixel marches a ray into the scene. Jelly pixels refract into the body, march again to the exit, and attenuate by the path length. The ground is invisible except for its soft shadow. Silhouettes are anti-aliased from the closest miss distance.
Develop
pnpm install
pnpm dev # demo on http://localhost:5180 — Docs (this reference, with a copy-as-Markdown button) at /docs; Playground (stage left, controls right; stacked on narrow screens) from the app bar; Board (every shape × expression) at /playground#board; light / dark toggle top right
pnpm test
pnpm build # library to dist/The demo is a Vite app in demo/ built with shadcn/ui on Tailwind v4. Add UI
components with the CLI, never by hand: pnpm dlx shadcn@latest add <component> (config in components.json,
components land in demo/components/ui). Sliders are the smoothui Scrubber, added the
same way from its shadcn registry: pnpm dlx shadcn@latest add https://smoothui.dev/r/scrubber.json (lands in
demo/components/smoothui/scrubber; the CLI may drop it under src/components, move it into demo/). The demo's
dependencies are devDependencies; the library itself has none.
Deploy the demo to Cloudflare Workers
The GitHub Actions workflow in .github/workflows/deploy.yml deploys the demo on
every push to main, including merged pull requests. It runs typechecking, tests,
and the Vite build before publishing dist-demo/ with Workers Static Assets.
You can also run the workflow manually from the Actions tab on main.
In the repository's Settings → Secrets and variables → Actions, add these repository secrets:
| Secret | Value |
|---|---|
| CLOUDFLARE_API_TOKEN | A Cloudflare API token created using the Edit Cloudflare Workers template, scoped to the deployment account |
| CLOUDFLARE_ACCOUNT_ID | The Cloudflare account ID for that account |
See Cloudflare's GitHub Actions setup guide
for token creation and account ID instructions. Ensure the account has a
workers.dev subdomain configured in Cloudflare.
The Worker name is ai-pet (change name in wrangler.jsonc if needed). The
deployment URL will be https://ai-pet.<your-subdomain>.workers.dev; Wrangler
prints the actual URL in the Actions deploy log. No custom domain is required.
To validate or deploy locally:
pnpm build:demo
pnpm exec wrangler deploy --dry-run # validate without publishing
pnpm exec wrangler login # authenticate for local deployment
pnpm deploy # build and publish the demoLicense
MIT
