This is a port, not original work. It is based on nasawz/GrokBot — a pure Flutter animated avatar widget by nasawz, licensed BSD-3-Clause. All of the design work is theirs: the 25 expression point sets, the 18 shape geometries, the 39 state expression pools and cadences, and the projection, spring and blink math. This repository translates that widget to TypeScript and the Canvas 2D API and adds nothing to its visual design. If you want the Flutter version, go upstream.
An animated GrokBot avatar for the web. 25 expressions, 18 body shapes, 39 behavior states, gaze tracking, head turn, spring expression morphs and blinks — drawn with the Canvas 2D API, in TypeScript, with zero runtime dependencies.
Because the data tables and the math are carried over unchanged, this port and the Flutter original render the same avatar.
npm install grokbot-webThree entry points, one engine underneath. Pick whichever suits the app.
import { GrokBot } from 'grokbot-web';
const bot = new GrokBot(document.querySelector('canvas')!, {
state: 'thinking',
shape: 'blob',
size: 160,
autoExpression: true,
});
await bot.blink();
await bot.spin({ turns: 1, durationMs: 1200 });
bot.update({ state: 'excited', gaze: { x: 0.3, y: -0.1 } });
bot.destroy(); // on teardown<script type="module">import 'grokbot-web/element';</script>
<grok-bot state="listening" shape="capsule" size="140" auto-expression></grok-bot>Attributes mirror the options in kebab-case (gaze-x, gaze-y, eye-scale,
spring-frequency, flip-x, show-guides, body-color, eye-color, label).
Booleans follow HTML rules — present means true — except auto-blink, which is
on by default and is turned off with auto-blink="false". The element also
exposes blink(), spin(), reset() and .bot.
import { useRef } from 'react';
import { GrokBotView, type GrokBotHandle } from 'grokbot-web/react';
import { darkTheme } from 'grokbot-web';
function Assistant({ busy }: { busy: boolean }) {
const bot = useRef<GrokBotHandle>(null);
return (
<>
<GrokBotView
ref={bot}
state={busy ? 'working' : 'idle'}
shape="squircle"
size={200}
autoExpression
theme={darkTheme}
label="Assistant avatar"
/>
<button onClick={() => bot.current?.blink()}>Blink</button>
</>
);
}The engine is created once per mount and patched on prop changes, so a React re-render never restarts an in-flight morph or blink.
| Option | Type | Default | What it does |
|---|---|---|---|
state |
GrokBotState |
'idle' |
Picks the expression pool and the automatic cadences |
shape |
GrokBotShape |
'blob' |
Silhouette plus its face geometry correction |
expression |
0…24 | null |
null |
Pins one expression; null hands control back to the state |
gaze |
{ x, y } |
{ x: 0, y: 0 } |
Normalized, clamped to [-1, 1]; maps to about ±13.2 × ±8.4 |
turn |
number |
0 |
Base head turn in radians; spin() adds on top |
eyeScale |
number |
1 |
Eye size multiplier, must be > 0 |
springFrequency |
number |
7 |
Natural frequency ω of the morph spring, must be > 0 |
autoBlink |
boolean |
true |
Blink on the state's cadence |
autoExpression |
boolean |
false |
Drift through the state's pool while no expression is pinned |
flipX |
boolean |
false |
Mirror horizontally |
emphasis |
boolean |
false |
Enlarge the eyes by 18% |
showGuides |
boolean |
false |
Draw the sphere guide and the projected eye centroids |
theme |
Partial<GrokBotTheme> |
light preset | Merged onto the current palette |
size |
number | 'auto' |
'auto' |
CSS pixels, or track the canvas box with ResizeObserver |
ariaLabel |
string |
'GrokBot' |
Written to the canvas as role="img" + aria-label |
respectReducedMotion |
boolean |
true |
Honour prefers-reduced-motion |
random |
() => number |
Math.random |
Randomness source, injectable for deterministic tests |
| Method | What it does |
|---|---|
update(patch) |
Applies a partial options patch, restarting only what changed |
blink() |
One 320 ms blink (42% closing, 58% opening, floor at 4% height) |
spin({ turns, durationMs }) |
Spins whole turns; a new spin completes and replaces the active one |
reset() |
Cancels transient animations and returns to the resting face |
render() |
Paints one frame immediately, outside the loop |
destroy() |
Stops every timer, frame and observer |
options, expression and isAnimating are readable properties.
39 states. Lifecycle: sleeping waking idle listening thinking
searching working. Reactions: excited surprised suspicious angry
drowsy happy curious confused bored proud shy sad laughing
scared playful celebrate. Agent shapes: orbit radar progress.
Product lifecycle: spawning humming loading dictating writing
sending receiving uploading notifying alerting dragging bouncing
powering-down.
18 shapes. blob pebble bean egg squircle tablet capsule
cylinder hex gem crystal wedge shield dome arch cloud
teardrop leaf.
25 expressions, indexed 0–24. Each is two closed 48-point rings.
The tables are exported, so a picker can be built from them:
import { stateNames, shapeNames, expressionIndexes, stateData } from 'grokbot-web';
stateData.thinking.expressions; // [8, 16, 14, 17, 5]
stateData.thinking.blinkCadence; // { min: 3500, max: 7000 }
stateData.sleeping.blinkCadence; // null — this state never blinksstate / timers
→ damped spring morph, integrated at a fixed 1/120 s step
→ lerp between two 48-point rings
→ gaze offset + spherical projection for the head turn
→ Canvas 2D: fill body, clip to it, fill both eyes
- Gaze. Normalized input is clamped and mapped to a face-space offset of about ±13.2 × ±8.4 units.
- Head turn. Each eye centroid's horizontal offset becomes a longitude on
the head sphere. The turn rotates it;
cos(longitude)both narrows the eye and hides it once it passes behind the head. Shapes flaggedsquashOnTurnalso compress the body, but not the face inside it. - Blink. 320 ms, closing faster than opening.
- Loop. The animation frame loop runs only while a morph, blink or spin is live. Idle avatars cost nothing.
Deliberate, and each for a reason:
- Canvas 2D, not SVG. The original's
CustomPaintmodel — nested transforms, a clip path, per-frame polygon fills — maps onto Canvas 2D one call at a time. SVG would mean rewriting 96 path points into the DOM every frame, which is slower and drifts from the original's semantics. - No separate controller type. A Flutter widget is immutable, so transient
animations need an external handle. A
GrokBotinstance is long-lived and owns both the declarative options and the imperative moments. - String literal types, not enums.
'powering-down'is the state's actual name, so it survives JSON, HTML attributes and query params with no mapping layer. This removes upstream'ssourceNameextension entirely. Cubic(0.645, 0.045, 0.355, 1.0)is reproduced exactly, including the fact that it is not quite point-symmetric —y(0.5) = 0.516875. Spin timing matches upstream frame for frame.- Web-only additions. Device pixel ratio handling,
ResizeObserversizing,prefers-reduced-motionsupport, an injectablerandom, and a first paint that does not wait for an animation frame — otherwise a bot constructed in a background tab stays blank until the tab is focused. - Rings are flat number arrays, not point objects: 4,800 numbers with no per-point allocation on the interpolation path.
Any browser with CanvasRenderingContext2D.ellipse — Chrome, Edge, Firefox,
Safari 13+. Rounded corners are traced with elliptical arcs rather than
roundRect, so no recent-Safari requirement sneaks in. ResizeObserver and
matchMedia are feature-detected; without them, pass an explicit size.
npm install
npm test # 55 tests: geometry, easing, painter, engine, custom element
npm run typecheck
npm run build
npm run demo # the playgroundThe expression table is generated from the upstream Dart source:
git clone https://github.com/nasawz/GrokBot .reference
npm run dataEvery bit of the avatar's design comes from nasawz/GrokBot by nasawz. This repository is a language and rendering-target port of that project:
| Carried over unchanged | Rewritten for the web |
|---|---|
| 25 expressions (2 eyes x 48 points each) | Canvas 2D painter, replacing CustomPaint |
| 18 shape geometries and their face corrections | Rounded-corner tracing, replacing RRect |
| 39 state expression pools, expression and blink cadences | Animation frame loop, replacing Ticker |
| Spherical eye projection, spring morph, blink and spin math | Options / patch API, replacing widget + controller |
| Theme presets | Sizing, DPR, reduced motion, tests |
Generated data files and every ported source file carry an upstream reference in
their header comment. scripts/convert_expressions.py shows exactly how the
expression table is derived from the upstream Dart source.
If this is useful to you, star the original.
BSD 3-Clause, the same as the original, and the upstream copyright notice is retained. See LICENSE.