The avatar was technically working. It greeted visitors. It displayed messages. It sat faithfully in the bottom-right corner.
And it still felt like a sticker.
The problem was not a lack of animation. The problem was a lack of attention. Real characters react to what people are doing. When someone looks at a project, the guide should become curious. When they focus an email field, it should become attentive. When validation fails, it should point attention toward the problem without covering the control.
Our visual reference was the open-source Bible Strong Avatar Lab. Its editor demonstrates a useful separation: avatar geometry, expressions, animations, and playback are different concerns. We translated that idea into a lightweight website-assistant architecture instead of copying its editor or assets.
The behavior model: react to attention, not raw movement
A mascot that chases every cursor coordinate quickly becomes exhausting. We used five interaction layers, each with a clear reason to exist:
Slow vertical float, tiny rotation, breathing, and occasional blink.
Small head or eye movement only. The whole widget does not chase the cursor.
Directional lean plus a curious message when a card or CTA is hovered.
Dock near the focused field, then react differently to invalid and successful states.
Step 1: build a semantic event layer
Page components should not know how the character is drawn. They should announce intent: “the user is curious about this project” or “this email is invalid.” A provider translates that intent into message, mood, direction, duration, and position.
type AvatarMood =
| 'idle' | 'curious' | 'attentive'
| 'concerned' | 'thinking' | 'celebrating'
type AvatarEvent = {
mood: AvatarMood
message: string
duration?: number
}
type AvatarAssistantAPI = {
showHint: (event: AvatarEvent, target?: HTMLElement | null) => void
returnToIdle: (delay?: number) => void
}
This keeps form validation state separate from animation state. Validation remains the form’s responsibility; the assistant simply responds to the result.
Step 2: calculate where the user’s attention is
Never hardcode “first card means left” or “email field means down.” Read the target’s bounding rectangle and compare its center with the viewport center.
function directionFor(target: HTMLElement) {
const rect = target.getBoundingClientRect()
const x = rect.left + rect.width / 2
const y = rect.top + rect.height / 2
return {
x: clamp((x - innerWidth / 2) / (innerWidth / 2), -1, 1),
y: clamp((y - innerHeight / 2) / (innerHeight / 2), -1, 1)
}
}
Those normalized values can drive a tiny body lean, eye translation, or sprite direction. They also survive responsive layouts because the calculation uses the element’s real position.
Step 3: safely dock beside a focused field
The avatar should feel “available on the input,” but never sit on top of the text box. We estimated the assistant footprint, aligned it with the field’s right edge, clamped it to the viewport, and selected above or below based on available space.
function dockFor(target: HTMLElement) {
const rect = target.getBoundingClientRect()
const width = matchMedia('(max-width: 640px)').matches ? 205 : 220
const height = matchMedia('(max-width: 640px)').matches ? 170 : 185
const gap = 12
const left = clamp(rect.right - width, gap, innerWidth - width - gap)
const above = rect.top >= height + gap
const top = above ? rect.top - height + 40 : rect.bottom + gap
return { left, top: Math.max(gap, top), below: !above }
}
The assistant remains position: fixed, so page layout never shifts. On scroll and resize, the target is measured again. When focus leaves, a short delay returns the assistant home instead of snapping immediately.
Step 4: wire real interactions
<input
onFocus={(event) => showHint({
mood: 'attentive',
message: 'What should I call you?'
}, event.currentTarget)}
onBlur={() => returnToIdle(900)}
/>
<ProjectCard
onPointerEnter={(event) => showHint({
mood: 'curious',
message: 'Want to see how this project works?'
}, event.currentTarget)}
/>
A casual hover must not overwrite an invalid-email warning or celebration. Give high-value states a short lock window, cancel older timers, and keep only one active target.
Step 5: animate transforms, not layout
.avatar {
position: fixed;
right: max(18px, env(safe-area-inset-right));
bottom: max(18px, env(safe-area-inset-bottom));
will-change: transform;
transition: transform 420ms cubic-bezier(.2,.8,.2,1),
left 420ms cubic-bezier(.2,.8,.2,1),
top 420ms cubic-bezier(.2,.8,.2,1);
}
@keyframes avatar-idle {
0%, 100% { transform: translate3d(0,2px,0) rotate(-2deg); }
50% { transform: translate3d(4px,-10px,0) rotate(2deg); }
}
@media (prefers-reduced-motion: reduce) {
.avatar, .avatar * { animation: none !important; transition: none !important; }
}
The copy-ready AI implementation prompt
Paste this into Codex, Claude Code, Cursor, or another coding agent while your existing project is open:
Inspect the existing website avatar implementation before editing. Preserve its current design, event handlers, form validation, and accessibility.
Upgrade it into a context-aware floating assistant:
1. Add a subtle idle float, small rotation, breathing, and blinking.
2. On desktop, track nearby pointer attention with a small head/eye/body turn. Do not chase the cursor.
3. When a project card or View Project CTA is hovered, calculate the element center from getBoundingClientRect(), lean toward it, show a curious supported expression, and display: “Want to see how this project works?”
4. When an input or textarea receives focus, move the assistant beside that exact field. Calculate a safe fixed position above or below the field, clamp it inside the viewport, and never cover the control.
5. For invalid email or required fields, show a concerned supported state and a helpful field-specific message. Keep validation logic separate from animation state.
6. On successful submission, play a small celebration and success message.
7. Return smoothly to the default bottom-right pose after interaction ends.
8. Recalculate the active target position on scroll and resize without causing layout shifts.
9. Use CSS transforms/transitions and requestAnimationFrame only for throttled pointer work. Clean up timers, listeners, and animation frames.
10. Disable excessive pointer tracking on touch devices and honor prefers-reduced-motion.
Use semantic states such as idle, curious, attentive, concerned, thinking, and celebrating. Add priority handling so validation and success cannot be overwritten by hover events. Keep the assistant keyboard accessible. Run lint, type checking, existing tests, and the production build. Report every changed file and test result.
The hydration warning that was not our bug
During testing, Next.js reported a server/client attribute mismatch on <body>. The unexpected attribute was injected by a browser extension before React hydrated. That distinction matters: suppressing every warning hides real problems. First compare the exact server/client diff, reproduce in a clean browser profile, and only use suppressHydrationWarning at the narrowest trustworthy boundary.
Production checklist
- ✓ Idle motion is subtle and continuous
- ✓ Project hover reacts directionally
- ✓ Focused fields receive safe docking
- ✓ Invalid and success states have priority
- ✓ Touch and keyboard flows still work
- ✓ Reduced-motion users get a calm UI
Reference and credit
Explore the original project that inspired the expression/playback separation: smontlouis/bible-strong-avatar-lab on GitHub. Its repository documents a browser-based avatar studio with reusable expressions, animation sequences, SVG rendering, portable avatar definitions, and React/web packages.
The best avatar is not the one that moves most.
It is the one that notices the same moment the user does.
Next: Build the custom face →