The Hackers' Pub mascot, in motion
A little curiosity.
A whole universe.
pubnyan is a calm cat with a star for a mouth and a ring around its neck. Every performance is written once, in a small motion language, and exported to animated SVG, Lottie, Rive and video that all agree with each other.
- Clips
- 22
- Targets
- 4
- Expressions
- 5
- Definition
- one
dist/svg/idle.svg · animated SVG in an <img>Interactive
Give pubnyan a thought.
One Rive file, one state machine. Pick a sustained expression, then fire a reaction on top of it: ordinary reactions keep the emotion underneath, and a celebration takes over the whole face for a moment, then hands it back.
Loading pubnyan.riv…
Expression sustained
Listening. An ear catches a sound, a glance, a lean.
Reaction one shot
Click the cat for a wink. Number keys work while the stage is on screen.
Try this
Choose shy, then Nod: the nod plays and the shyness stays. Then Celebrate.
Every clip
Performance, not constant movement.
A thought, a small preparation, one readable accent, a hold, and a diminishing recovery. The pupils notice first. The head follows. The ring, one rigid object, counterbalances the cat and settles last.
One performance, several stages
The same wink, in every format.
Each target is rendered in headless Chrome and diffed against the reference sampler before it ships, so what you embed is what was authored.
Animated SVG
Self-contained CSS keyframes. Works in an <img>, a CSS background, or inline. Shapes morph with d: path() (Chromium, Firefox 97+); everything else crossfades and is universal.
<img src="idle.svg" alt="pubnyan" width="406" height="351">
Lottie
One JSON per clip plus a .lottie bundle of all of them. Plays in lottie-web, dotLottie and every native Lottie player.
lottie.loadAnimation({
container, renderer: 'svg', loop: true,
path: 'idle.json',
});
Rive
One .riv per clip, and pubnyan.riv with the main state machine: an expression enum and one trigger per reaction. This is what the stage above runs.
const cat = new Rive({
canvas, src: 'pubnyan.riv',
stateMachines: 'main', autoplay: true,
});
const inputs = cat.stateMachineInputs('main');
inputs.find(i => i.name === 'expression').value = 2; // curious
inputs.find(i => i.name === 'reactNod').fire();
- pubnyan.riv state machine
- idle.riv
- wink.riv
- celebrate.riv
Video
MP4 for players, animated WebP for the web, GIF for the places that still want one. Rendered frame by frame from the same sampler.
<video src="idle.mp4" autoplay muted loop playsinline></video>
Every clip in every format is in the Storybook galleries and in dist/.
How it works
Written once. Checked everywhere.
-
01
Rig
The original artwork is a git submodule.
parts.map.jsonnames its paths (head, ears, eyes, ring) andrig:extractturns them into a rig with pivots. -
02
Clip
A clip is a list of tracks: a part, a property, and keys with easings. Amplitudes stay small. pubnyan is a calm cat.
-
03
Export
The same intermediate representation is sampled into CSS keyframes, Lottie layers, a Rive artboard with a state machine, and video frames.
-
04
Verify
Every target is rendered in headless Chrome and pixel-diffed against the reference sampler. The contact sheet is judged by eye as well.
motion/clips/wink.clip.tsexport default clip('wink', { rig: 'pubnyan', duration: 0.95, fps: 60, loop: false }, [
...['eye-r.white', 'eye-r.pupil'].map((part) => track(part, 'scale', [
key(0, [1, 1]), key(0.13, [1, 1]), key(0.21, [1, 0.08], 'easeIn'),
key(0.39, [1, 0.08]), key(0.57, [1, 1], 'outCubic'), key(0.95, [1, 1]),
])),
track('head', 'rotation', [
key(0, 0), key(0.1, 0.9, 'inOutCubic'), key(0.25, -4, 'outCubic'),
key(0.43, -4), key(0.69, 0.55, 'inOutCubic'), key(0.95, 0, 'inOutSine'),
]),
track('ear-r', 'rotation', [
key(0, 0), key(0.18, 0), key(0.32, -4.5, 'outCubic'),
key(0.49, 1.2, 'inOutCubic'), key(0.66, -0.4, 'inOutSine'), key(0.95, 0, 'inOutSine'),
]),
...orbit('rotation', [key(0, 0), key(0.2, 0), key(0.42, 1.5, 'outCubic'), key(0.95, 0, 'inOutSine')]),
]);