Gourav Thakur

Bobblehead

A character for the top of your page that watches the cursor and reacts when you poke it. Sixteen head directions instead of nine, a spring that keeps it moving between them, idle breathing, and blinking that works in whatever direction it is already facing.

v0.1.0MITReact 18+no dependencies
npm i bobblehead

Or copy src/mascot.tsx into your project. It is one file, needs only React, and uses inline styles, so it drops into any setup without a CSS framework.

Playground

Move the cursor around it, then click. The code underneath is the mascot you are looking at, with whatever the controls are set to.

watching the cursor

size

rendered px

160

idle

breathing, idle blinking

blink

the eyes-shut ring

import { Mascot } from 'bobblehead'

<Mascot
  directions="/mascots/jojo-directions.webp"
  reactions="/mascots/jojo-reactions.webp"
  blink="/mascots/jojo-blink.webp"
  size={160}
  label="bobblehead"
/>

Directions

Sixteen sectors of 22.5 degrees, clockwise from east. The dial reads your pointer with the package's own arithmetic, so what lights up here is the cell the mascot would draw.

0123456789101112131415
sectors
angle
—
distance
—
cell
16 · rest

cell = round(atan2(dy, dx) / (2π / 16)) mod 16

The index is the rounded angle, so there is no lookup table. Clockwise from east is what atan2 already gives when y points down.

Move the cursor anywhere on the page. The dial reads it exactly as the mascot does, dead zone and hysteresis and all: the sector holds until the pointer is past its edge, so a head sitting on a boundary never flutters between two poses.

Head directions
8 sectors, 45°
16 sectors, 22.5°
Between sectors
frozen
a spring: parallax, yaw, pitch, roll
At rest
frozen
breathing, and a blink every 4-9s
Blinking
snaps front to blink
blinks facing wherever it is looking
Reactions reachable
5 of 9
9 of 9

Reverse-engineered from nilbuild/page-mascot, whose art pipeline this builds on. The runtime is rewritten.

Reactions

Nine expressions on one sheet. Five of them take turns on a click, the rest wait for something more specific.

0blink
The first 120ms of a click, and idle blinking when no blink sheet is given.
1heart
A click. First in the rotation.
2sparkle
A click. Second in the rotation.
3surprised
The pointer rushing into a 160px radius at over 1200px/s.
4wink
A click. Fourth in the rotation.
5bashful
A click. Fifth, then back to the start.
6sleepy
The pointer completely still for 12s. Clears on the next movement.
7dizzy
Four clicks within 1.6s.
8delighted
A click. Third in the rotation.

A reaction holds for 2.5s and comes off this front-facing sheet, so the head does face you while one is showing. Idle blinking is the exception: it uses the blink ring instead and keeps its direction.

Props

Two sheets are required. Everything else has a sensible default.

directionsstringrequired

The 6x3 sheet of head angles.

reactionsstringrequired

The 3x3 sheet of expressions.

blinkstring—

The 6x3 eyes-shut ring. Without it, blinking falls back to the front-facing expressions sheet, so the head snaps straight to blink and back.

sizenumber140

Rendered width and height in px.

labelstring'mascot'

What a screen reader calls it. The button reads “Boop the …”.

classNamestring—

Applied to the button.

idlebooleantrue

Breathing and idle blinking. Off leaves cursor tracking untouched.

Behaviour

What it does when nobody is watching, and what it refuses to do.

Desktop only tracking

Gated on (hover: hover) and (pointer: fine). On touch the mascot renders and stays clickable, it just does not follow anything.

Reduced motion

Under prefers-reduced-motion the spring, the breathing, the idle blinking and the click squash all drop out. The head still turns.

Cheap when unwatched

The render loop stops when the mascot scrolls out of view or the tab is hidden, and a head turn never goes through a React render.

One cell, cut not blended

Crossing two sprite cells composites source-over and the character goes translucent mid-turn. A cut cannot do that, and sixteen directions plus the spring carry the smoothness instead.

Where to put it

Wherever a head watching the cursor reads best. Usually the header or the hero, above or beside the title.

The sheets

Three atlases: a ring of head angles, the same ring with the eyes shut, and nine expressions. The cell index is the rounded angle, so there is no lookup table.

sheet
6x3 · 360px cells
  1. 0
  2. 1
  3. 2
  4. 3
  5. 4
  6. 5
  7. 6
  8. 7
  9. 8
  10. 9
  11. 10
  12. 11
  13. 12
  14. 13
  15. 14
  16. 15
  17. 16rest
  18. 17pad

Cells 0 to 15 run clockwise from east, so cell 4 looks straight down and cell 12 straight up. The blink ring matches the directions sheet cell for cell, which is what lets the mascot keep tracking with its eyes shut.

Your own character

The pipeline draws, screens, builds and verifies a character from one reference photo. It lives in the repo rather than the package, and needs Python 3 and an OPENAI_API_KEY.

git clone https://github.com/GouravAkaJojo/bobblehead && cd bobblehead
python3 -m venv .venv && .venv/bin/pip install pillow numpy scipy openai
export OPENAI_API_KEY=sk-...
.venv/bin/python scripts/mascot.py me --reference ~/photo.jpg \
  --describe "short dark hair, a trimmed beard, black round glasses, a navy tee"

.venv/bin/python scripts/mascot.py me --only lower    # the downward arc
.venv/bin/python scripts/mascot.py me --only blink    # the eyes-shut ring

Describing the character matters more than anything else here. Name the colours and two or three distinguishing features in one sentence. Avoid long loose hair over the shoulders: it gets drawn differently in each sheet, and the mascot lurches when clicked. Tied back or under a hat is safe.

--stylecolouranimejjbainksketchrisopaperpixel

A style changes only how the character is drawn. The framing, the head-to-shoulder proportions and the one reused body stay fixed, because the alignment depends on them.

.venv/bin/python scripts/verify.py me   # does it move or change size when clicked
.venv/bin/python scripts/ring.py me     # is any pose in the wrong cell

verify.py refuses more than 2px of shift on click, a palette match below 22% between the two sheets, or a shoulder-width change above 12%. ring.py catches a correct drawing sitting in the wrong cell, which every other check scores as perfect. To exercise the atlas geometry and the spring without spending image credits, scripts/testsheet.py draws a crude 16-angle character to build from.

The art pipeline, the sprite-sheet approach and the prompt engineering behind it are from nilbuild/page-mascot. The runtime here is rewritten, and NOTES.md covers why: what blending does to a sprite sheet and why frames are cut instead, why the downward arc needs its own sheet, and which measurements catch which failures.