An animated 3D character that lives on your webpage — walking, pointing, dancing, and reacting to visitors in real time, right in the browser.
Available as a React component or a framework-agnostic web component.
Live demo: pagecompanion.web.app
This is a monorepo containing three publishable packages:
| Package | Description | Size (gzip) |
|---|---|---|
@unopsitg/page-assistant-core |
Shared types, constants, and utility functions (no framework dependency) | ~3 KB |
@unopsitg/page-assistant-react |
React component with Three.js rendering (requires React + Three.js as peer deps) | ~30 KB |
@unopsitg/page-assistant-web-component |
<page-assistant> custom element — self-contained, works with any framework |
~580 KB |
- Walk anywhere — click any spot on the page and the character walks there, scrolling if needed
- Walk to elements — programmatically guide the character to any DOM element by selector
- Gestures — wave, point, talk, and dance animations with configurable duration
- Point at elements — IK-driven arm pointing at any DOM element, screen coordinates, or selector (auto-picks left/right arm based on relative position)
- Guided tour — JSON-driven multi-step tours that walk the character between elements, play actions, display speech bubbles, and optionally narrate each step with text-to-speech
- Text-to-speech — narrate any text via the Web Speech API with configurable voice preferences (language, gender, neural/online quality) and chunked utterance support
- Speech bubble — floating UI bubble anchored to the character's head with title, description, and optional listen/stop controls; auto-flips near viewport edges
- Lip sync — jaw bone animates open/closed while speech is playing for a visual talking effect
- Head tracking — the character follows the user's cursor with head, neck, and spine bone overrides
- Cursor follow with arms — optional arm IK that reaches toward the pointer in addition to head tracking
- Look at elements — direct the character's gaze toward a specific element
- 9 characters — Amy, Sophie, Michelle, AJ, Boss, Brian, Doozy, Joe, and Mousey (Mixamo rigs, optimized GLB)
- 3 themes — Midnight (dark), Light, and Grey, controlled via CSS custom properties
- Container mode — embed the assistant in a sized container instead of the default full-viewport overlay
- Full API — a React hook (
usePageAssistant) or web component methods expose walk, gesture, look, speech, tour, visibility, and event methods - Accessibility — respects
prefers-reduced-motionand provides areducedMotionprop - Responsive — adjusts camera and layout for mobile viewports; tours hide the bubble on small screens when auto-speak is active
![]() AJ |
![]() Amy |
![]() Boss |
![]() Brian |
![]() Doozy |
![]() Joe |
![]() Michelle |
![]() Mousey |
![]() Sophie |
| Layer | Technology |
|---|---|
| UI | React 19, TypeScript 5.9 |
| 3D | Three.js 0.183, React Three Fiber 9 |
| Build | Vite 8 (monorepo with npm workspaces) |
| Hosting | Firebase Hosting |
npm install @unopsitg/page-assistant-core @unopsitg/page-assistant-reactimport { PageAssistantProvider, usePageAssistant } from '@unopsitg/page-assistant-react';
function App() {
return (
<PageAssistantProvider characterId="amy">
<MyPage />
</PageAssistantProvider>
);
}
function MyPage() {
const assistant = usePageAssistant();
return (
<div>
<button onClick={() => assistant.walkTo('#pricing')}>Walk to Pricing</button>
<button onClick={() => assistant.pointAt('#signup', { walkTo: true })}>Point at Signup</button>
<button onClick={() => assistant.wave()}>Wave</button>
<button onClick={() => assistant.dance()}>Dance</button>
<button onClick={() => assistant.lookAtCursor()}>Follow Cursor</button>
<button onClick={() => assistant.say('Welcome to our site!')}>Speak</button>
<button onClick={() => assistant.startTour({
steps: [
{ element: '#features', action: 'walkTo', popover: { title: 'Features', description: 'Check out what we offer.' } },
{ element: '#pricing', action: 'pointAt', walkTo: true, popover: { title: 'Pricing', description: 'Affordable plans for everyone.' } },
],
speechEnabled: true,
autoSpeak: true,
})}>Start Tour</button>
<section id="features">...</section>
<section id="pricing">...</section>
</div>
);
}Peer dependencies — your project must also have react, react-dom, three, @react-three/fiber, and meshoptimizer installed.
npm install @unopsitg/page-assistant-web-component<script type="module">
import '@unopsitg/page-assistant-web-component';
</script>
<page-assistant character-id="amy"></page-assistant>import '@unopsitg/page-assistant-web-component';
const pa = document.querySelector('page-assistant');
// Movement
await pa.walkTo('#pricing');
await pa.walkToPosition(400, 300);
// Gestures
await pa.wave();
await pa.dance();
await pa.pointAt('#signup', { walkTo: true });
// Speech
pa.say('Hello, welcome to my page!');
pa.stopSpeaking();
pa.showBubble({ title: 'Welcome', description: 'Let me show you around.' });
pa.hideBubble();
// Tour
pa.startTour({
steps: [
{ element: '#features', action: 'walkTo', popover: { title: 'Features', description: 'See our amazing features!' } },
{ element: '#pricing', action: 'pointAt', walkTo: true, popover: { title: 'Pricing', description: 'Pick a plan.' } },
],
speechEnabled: true,
autoSpeak: true,
});
pa.nextStep();
pa.stopTour();
// Events
pa.addEventListener('statechange', (e) => console.log(e.detail.state));
pa.addEventListener('assistantclick', () => console.log('Clicked!'));
pa.addEventListener('assistanthover', (e) => console.log('Hover:', e.detail.hovering));| Attribute | Description | Default |
|---|---|---|
character-id |
Character to render (e.g. "amy", "boss") |
First key in characters |
initially-visible |
Set to "false" to start hidden |
"true" |
reduced-motion |
Set to "true" to disable the 3D canvas |
— |
container-mode |
Present to render in a sized container | — |
width |
Container width (when container-mode is set) |
— |
height |
Container height (when container-mode is set) |
— |
sticky-header-selector |
CSS selector for a sticky header (adjusts scroll offset) | — |
| Property | Type | Description |
|---|---|---|
characters |
Record<string, CharacterDefinition> |
Custom character definitions (set via JS, not as an attribute). See Custom Characters. |
The web component bundles React, Three.js, and R3F internally — consumers do not need to install any peer dependencies.
Both the React hook (usePageAssistant()) and the web component element expose the same PageAssistantAPI surface.
| Method | Description |
|---|---|
walkTo(target, options?) |
Walk to a DOM element or CSS selector. Scrolls the page if needed. |
walkToPosition(screenX, screenY, options?) |
Walk to screen coordinates. |
setPosition(screenX, screenY) |
Snap to a screen X position instantly. |
| Method | Description |
|---|---|
wave(options?) |
Play the wave animation (one-shot). |
point(options?) |
Play the point animation (one-shot). |
pointAt(target, options?) |
Point at a DOM element, selector, or {x, y} screen coordinates using IK arm aiming. options.walkTo walks to the element first. |
talk(options?) |
Play the talk animation (looping). |
dance(options?) |
Play the dance animation (looping). |
idle() |
Return to idle. |
| Method | Description |
|---|---|
turnLeft() |
Rotate the character to face left. |
turnRight() |
Rotate the character to face right. |
straightenUp() |
Reset rotation to face forward. |
| Method | Description |
|---|---|
lookAt(target) |
Turn head/neck/spine toward a DOM element or selector. |
lookAtCursor() |
Follow the user's cursor with head tracking. |
followCursorWithArms() |
Follow the cursor with head tracking and IK arm reaching. |
stopFollowingCursorWithArms() |
Stop arm following (head tracking continues if active). |
lookForward() |
Reset head to look forward. |
| Method | Description |
|---|---|
show() |
Show the character. |
hide() |
Hide the character. |
isVisible |
Read-only — whether the character is visible. |
React — callback-based:
| Method | Description |
|---|---|
onStateChange(callback) |
Subscribe to state changes. Returns an unsubscribe function. |
onClick(callback) |
Subscribe to clicks on the character. Returns an unsubscribe function. |
onHover(callback) |
Subscribe to hover state changes. Returns an unsubscribe function. |
Web component — also dispatches CustomEvents:
| Event | detail |
Description |
|---|---|---|
statechange |
{ state: AssistantState } |
Fires when the character's state changes. |
assistantclick |
— | Fires when the character is clicked. |
assistanthover |
{ hovering: boolean } |
Fires when cursor enters/leaves the character. |
| Method | Description |
|---|---|
say(text, options?) |
Speak text aloud using the Web Speech API. Animates the jaw while speaking. |
stopSpeaking() |
Stop any in-progress speech. |
getAvailableVoices() |
Return the list of SpeechSynthesisVoice objects available in the browser. |
| Method | Description |
|---|---|
showBubble(data) |
Display a speech bubble anchored to the character's head with title, description, and optional showPlayButton. |
hideBubble() |
Hide the speech bubble. |
| Method / Property | Description |
|---|---|
startTour(config) |
Start a guided tour. The character walks between elements, performs actions, shows speech bubbles, and optionally narrates each step. |
nextStep() |
Advance to the next tour step (skips any remaining hold time). |
prevStep() |
Go back to the previous tour step. |
restartTour() |
Restart the tour from step 1. |
stopTour() |
Stop the tour and return to idle. |
isTourActive |
boolean — whether a tour is currently running. |
currentTourStep |
number — zero-based index of the active step. |
tourStepCount |
number — total number of steps in the active tour. |
| Property | Type | Description |
|---|---|---|
currentState |
AssistantState |
One of idle, walking, pointing, pointingAt, waving, talking, dancing, hidden. |
isFollowingCursor |
boolean |
Whether cursor tracking is active. |
isFollowingWithArms |
boolean |
Whether arm IK cursor tracking is active. |
interface WalkOptions {
speed?: number;
onArrive?: () => void;
}
interface GestureOptions {
duration?: number;
returnToIdle?: boolean;
}
interface PointAtOptions extends GestureOptions {
walkTo?: boolean; // walk to the element before pointing
}
interface SpeechOptions {
voice?: string | VoicePreference;
}
interface VoicePreference {
lang?: string; // BCP 47 language tag, e.g. "en-US"
gender?: 'male' | 'female';
quality?: 'neural' | 'online' | 'any';
name?: string; // exact SpeechSynthesisVoice.name override
}interface TourConfig {
steps: TourStep[];
animate?: boolean; // enable walk animations between steps (default true)
showSpeechBubble?: boolean; // show bubble for all steps (default true)
speechEnabled?: boolean; // enable TTS for all steps
autoSpeak?: boolean; // auto-play TTS when each step starts
defaultVoice?: string | VoicePreference;
onStart?: () => void;
onComplete?: () => void;
onStepChange?: (stepIndex: number, step: TourStep) => void;
onDestroyed?: () => void;
}
interface TourStep {
element?: string; // CSS selector for the target element
action?: 'walkTo' | 'pointAt' | 'wave' | 'talk' | 'dance' | 'idle';
popover?: { title?: string; description?: string };
duration?: number; // hold time in ms (auto-calculated from speech or text length if omitted)
walkTo?: boolean; // for pointAt action: walk to the element first
voice?: string | VoicePreference; // per-step voice override
speechEnabled?: boolean; // per-step TTS override
autoSpeak?: boolean; // per-step auto-speak override
showSpeechBubble?: boolean; // per-step bubble override
onHighlighted?: () => void;
onDeselected?: () => void;
}| Prop | Type | Default | Description |
|---|---|---|---|
characterId |
string |
First key in characters |
Character to render. |
characters |
Record<string, CharacterDefinition> |
Built-in CHARACTERS |
Custom character definitions (replaces the built-in set entirely). |
containerMode |
boolean |
false |
Render in a sized container instead of full-viewport overlay. |
width |
string | number |
— | Container width (when containerMode is true). |
height |
string | number |
— | Container height (when containerMode is true). |
className |
string |
— | CSS class for the canvas wrapper. |
initiallyVisible |
boolean |
true |
Whether the character is visible on mount. |
reducedMotion |
boolean |
false |
Disable the assistant entirely (also respects prefers-reduced-motion). |
stickyHeaderSelector |
string |
— | CSS selector for a sticky header element (used to offset scroll calculations). |
By default, the assistant ships with 9 built-in characters (Amy, Sophie, Michelle, etc.). You can replace these entirely by passing your own characters record. Each character needs a Mixamo-rigged GLB model with the standard animation clips (Idle, Walk, Point, Wave, Talk, Dance).
interface CharacterDefinition {
id: string; // Unique key (must match the record key)
label: string; // Display name
sex: 'male' | 'female'; // Used for voice selection heuristics
modelPath: string; // URL to the .glb file
modelHeight: number; // Approximate height in world units (used for scroll/walk)
modelScale: number; // Scale factor applied to the model
maxArmIkAngle?: number; // Max arm IK reach angle in radians (default π × 0.75)
lightingOverrides?: {
fillLightIntensity?: number; // Fill light intensity (default 0)
directionalIntensity?: number; // Directional light intensity (default 1.5)
emissiveIntensity?: number; // Emissive boost for materials (default 0)
};
}import { PageAssistantProvider } from '@unopsitg/page-assistant-react';
import type { CharacterDefinition } from '@unopsitg/page-assistant-react';
const MY_CHARACTERS: Record<string, CharacterDefinition> = {
robot: {
id: 'robot',
label: 'Robot',
sex: 'male',
modelPath: '/models/robot.glb',
modelHeight: 1.47,
modelScale: 1,
lightingOverrides: {
directionalIntensity: 2.0,
},
},
fairy: {
id: 'fairy',
label: 'Fairy',
sex: 'female',
modelPath: 'https://cdn.example.com/models/fairy.glb',
modelHeight: 1.2,
modelScale: 0.8,
maxArmIkAngle: Math.PI * 0.5,
},
};
function App() {
return (
<PageAssistantProvider characters={MY_CHARACTERS} characterId="robot">
<MyPage />
</PageAssistantProvider>
);
}import '@unopsitg/page-assistant-web-component';
const pa = document.querySelector('page-assistant');
pa.characters = {
robot: {
id: 'robot',
label: 'Robot',
sex: 'male',
modelPath: '/models/robot.glb',
modelHeight: 1.47,
modelScale: 1,
},
};
// Then set the character-id attribute or leave it to use the first key
pa.setAttribute('character-id', 'robot');Custom models must be GLB files containing a Mixamo-compatible skeleton with the following named animation clips:
| Clip name | Type | Description |
|---|---|---|
Idle |
Looping | Breathing / weight shift |
Walk |
Looping | Walk cycle (in-place) |
Point |
One-shot | Pointing gesture |
Wave |
One-shot | Waving gesture |
Talk |
Looping | Talking / explaining |
Dance |
Looping | Dance animation |
The skeleton must use Mixamo bone naming (mixamorigHead, mixamorigLeftArm, etc.). See the 3D Assets section for how to prepare models.
If you want to use the built-in character set but serve the models from a different URL, spread and override the modelPath:
import { CHARACTERS } from '@unopsitg/page-assistant-core';
import type { CharacterDefinition } from '@unopsitg/page-assistant-core';
const BASE = 'https://cdn.example.com/page-assistant';
const myCharacters: Record<string, CharacterDefinition> = Object.fromEntries(
Object.entries(CHARACTERS).map(([key, char]) => [
key,
{ ...char, modelPath: `${BASE}${char.modelPath}` },
]),
);
// myCharacters.amy.modelPath → "https://cdn.example.com/page-assistant/models/amy.glb"- Node.js 20+
- npm 10+
npm installThis installs root dependencies and links the workspace packages (packages/*).
npm run devOpens a local Vite dev server (default http://localhost:5173) running the demo app. The demo imports from @unopsitg/page-assistant-react and @unopsitg/page-assistant-core — Vite resolves these to the package source directories for live reloading.
npx tsc --noEmitEach package has its own build step that produces a dist/ folder with ES module output and TypeScript declarations.
npm run build:packagesThis runs build:core, then build:react, then build:wc in sequence (order matters because react depends on core, and web-component depends on both).
npm run build:core # @unopsitg/page-assistant-core
npm run build:react # @unopsitg/page-assistant-react
npm run build:wc # @unopsitg/page-assistant-web-component| Package | Output | Contents |
|---|---|---|
@unopsitg/page-assistant-core |
packages/core/dist/ |
index.js + .d.ts type declarations |
@unopsitg/page-assistant-react |
packages/react/dist/ |
index.js + .d.ts type declarations (react, three externalized) |
@unopsitg/page-assistant-web-component |
packages/web-component/dist/ |
page-assistant.js + .d.ts (self-contained, React/Three bundled) |
npm run buildOutputs the demo site to dist/ at the repo root.
- An npm account
- Logged in:
npm login - Packages are scoped under
@unopsitg— you must be a member of the unopsitg npm organization, or publish with--access public
All three packages are versioned in lockstep. Use the helper script to bump them all at once (it also updates inter-package dependency versions):
npm run version:set -- 0.2.0
npm install # sync the lockfilenpm run build:packagesBefore publishing, check that each package includes only what's intended:
npm -w @unopsitg/page-assistant-core pack --dry-run
npm -w @unopsitg/page-assistant-react pack --dry-run
npm -w @unopsitg/page-assistant-web-component pack --dry-runEach should contain only dist/ files plus package.json.
Publish in dependency order (core first, then react, then web-component):
npm -w @unopsitg/page-assistant-core publish --access public
npm -w @unopsitg/page-assistant-react publish --access public
npm -w @unopsitg/page-assistant-web-component publish --access publicThe --access public flag is required for scoped packages on the first publish. After the first publish, it's remembered and can be omitted.
npm info @unopsitg/page-assistant-core
npm info @unopsitg/page-assistant-react
npm info @unopsitg/page-assistant-web-componentnpm -w @unopsitg/page-assistant-core version 0.2.0-beta.1
npm -w @unopsitg/page-assistant-react version 0.2.0-beta.1
npm -w @unopsitg/page-assistant-web-component version 0.2.0-beta.1
npm run build:packages
npm -w @unopsitg/page-assistant-core publish --access public --tag beta
npm -w @unopsitg/page-assistant-react publish --access public --tag beta
npm -w @unopsitg/page-assistant-web-component publish --access public --tag betaConsumers can install with npm install @unopsitg/page-assistant-react@beta.
Character models start as Mixamo FBX files and are converted to optimized GLB files for runtime use. The conversion merges 7 FBX files per character into a single compressed GLB (typically 99%+ size reduction).
Go to mixamo.com (requires an Adobe account). For each character, download 7 FBX files — one base mesh and six animations.
Base mesh (T-Pose):
- Search for and select the character (e.g. "Amy").
- Download with: Format FBX Binary (.fbx), Pose T-Pose, With Skin.
Animations (6 clips):
For each animation below, search Mixamo, preview it on your character, then download with: Format FBX Binary (.fbx), Without Skin.
| File name | Mixamo search term | Notes |
|---|---|---|
<char>-idle.fbx |
"Breathing Idle" or "Happy Idle" | Looping. Subtle breathing/weight shift. |
<char>-walk.fbx |
"Walking" | Looping. "In Place" must be checked. |
<char>-point.fbx |
"Pointing" or "Pointing Gesture" | One-shot. Arm extended forward. |
<char>-wave.fbx |
"Waving" | One-shot. Greeting gesture. |
<char>-talk.fbx |
"Talking" or "Explaining Gesture" | Looping. Hands move as if explaining. |
<char>-hiphop.fbx |
"Hip Hop Dancing" | Looping. Fun/easter-egg dance. |
Download tips:
- Always select your character first so the preview confirms the animation works with their rig.
- For the Walk clip, ensure "In Place" is checked — the character's legs animate but the root stays stationary (translation is controlled in code).
- Adjust the Arm Space slider if arms clip through the body on any animation.
- Use 60 FPS (Mixamo default). Do not reduce to 30.
Rename downloaded files to match the naming convention and place them under public/mixamo_files/<character>/:
public/mixamo_files/<character>/
├── <character>-tpose.fbx ← With Skin
├── <character>-idle.fbx ← Without Skin
├── <character>-walk.fbx ← Without Skin, In Place
├── <character>-point.fbx ← Without Skin
├── <character>-wave.fbx ← Without Skin
├── <character>-talk.fbx ← Without Skin
└── <character>-hiphop.fbx ← Without Skin
Characters: amy, sophie, michelle, aj, boss, brian, doozy, joe, mousey.
The conversion script merges all 7 FBX files into a single optimized .glb and writes it to public/models/.
# Convert a single character
npm run convert-model -- amy
# Convert all characters at once
npm run convert-modelWhat the script does:
- Converts each FBX to a temporary GLB via FBX2glTF
- Loads the base character mesh (T-Pose with skeleton and textures)
- Merges all 6 animation clips into the base document, matching bones by name
- Resamples keyframes (removes redundant 60fps frames)
- Deduplicates accessors and textures
- Prunes unused resources
- Quantizes vertex data (positions, normals, UVs)
- Compresses textures to WebP at target resolution
- Applies meshopt compression on geometry and animation data
- Writes the final
.glbtopublic/models/<character>.glb
Tuning parameters:
Edit buildCharacterConfig() in scripts/convert-mixamo.ts to adjust conversion settings:
| Parameter | Default | Description |
|---|---|---|
textureSize |
1024 |
Max texture dimension in pixels. Lower to 512 for smaller files (mobile). |
textureFormat |
'webp' |
'webp' for smallest size, 'png' if WebP causes visual issues. |
compressionLevel |
'medium' |
Meshopt level: 'low', 'medium', or 'high'. Higher = smaller but slower to encode. |
keepIntermediate |
false |
Set to true to also write an uncompressed GLB for debugging in glTF Viewer. |
├── packages/
│ ├── core/ @unopsitg/page-assistant-core
│ │ ├── src/
│ │ │ ├── types.ts Shared types (PageAssistantAPI, AssistantState, TourConfig, …)
│ │ │ ├── constants.ts Characters, bone names, animation config
│ │ │ ├── voice.ts Voice resolution, scoring, tagging
│ │ │ ├── dom.ts DOM utilities (element resolution, centering)
│ │ │ ├── scroll.ts Smooth scroll with sticky header support
│ │ │ ├── math.ts Arm/turn computation
│ │ │ └── index.ts Barrel export
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ ├── tsconfig.build.json
│ │ └── vite.config.ts
│ ├── react/ @unopsitg/page-assistant-react
│ │ ├── src/
│ │ │ ├── usePageAssistantEngine.ts Core engine hook (state, effects, API)
│ │ │ ├── PageAssistantProvider.tsx Context provider for React apps
│ │ │ ├── PageAssistantStandalone.tsx Ref-based standalone (used by web component)
│ │ │ ├── PageAssistantOverlay.tsx Canvas + speech bubble rendering
│ │ │ ├── AssistantCanvas.tsx R3F Canvas, camera, lighting
│ │ │ ├── CharacterModel.tsx GLB loading, animation mixer, walking
│ │ │ ├── BoneOverrideController.tsx Head/neck/spine look-at & arm IK
│ │ │ ├── SpeechBubble.tsx Floating bubble anchored to character head
│ │ │ ├── useSpeech.ts Web Speech API React hook
│ │ │ ├── useCursorTracking.ts Mouse/touch position tracking
│ │ │ ├── useScreenToWorld.ts Screen-to-world coordinate projection
│ │ │ ├── types.ts Three.js-dependent types (BoneRefs, Controller)
│ │ │ ├── styles.ts CSS-in-JS for bubble and loader
│ │ │ └── index.ts Barrel export
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ ├── tsconfig.build.json
│ │ └── vite.config.ts
│ └── web-component/ @unopsitg/page-assistant-web-component
│ ├── src/
│ │ ├── page-assistant-element.tsx Custom element class
│ │ └── index.ts Registration + exports
│ ├── package.json
│ ├── tsconfig.json
│ ├── tsconfig.build.json
│ └── vite.config.ts
├── src/ Demo application
│ ├── main.tsx React root
│ ├── App.tsx Demo page with controls, themes, character picker
│ ├── App.css Demo layout and control panel styles
│ └── index.css Global styles and theme variables
├── scripts/
│ └── convert-mixamo.ts FBX-to-GLB conversion script
├── public/
│ ├── models/ Optimized GLB character files (generated)
│ └── mixamo_files/ Source FBX files from Mixamo (not committed)
├── index.html Demo entry point
├── package.json Root workspace config + demo deps
├── vite.config.ts Demo Vite config (with workspace aliases)
├── tsconfig.json Root TypeScript config (with workspace paths)
└── firebase.json Firebase Hosting config
npm run build
firebase deploy --only hosting:pagecompanionMIT









