A mascot inside a web application should do more than play the same looping animation.
A properly designed interactive mascot can:
- React to application events
- Follow the user's pointer
- Change emotions
- Enter listening, thinking, and talking states
- Celebrate successful actions
- Respond to errors
- Guide users through onboarding
- Connect to an AI assistant or text-to-speech system
In this tutorial, we will integrate an interactive Rive mascot into a React or Next.js application and control it using application logic.
We will build a mascot with:
- Pointer-controlled eye movement
- Multiple emotional states
- Talking controls
- A celebration trigger
- Rive Data Binding and ViewModels
- A reusable React component
Why use Rive for an interactive mascot?
Traditional GIF, video, and Lottie animations are useful for linear playback. However, an interactive product mascot needs to respond dynamically to what is happening inside the application.
Rive allows designers and developers to place multiple character behaviours inside a single .riv file.
The application can then control properties such as:
emotion = happy
isTalking = true
lookX = 0.65
lookY = -0.20
celebrate = trigger
This makes the character part of the product interface rather than a decorative animation placed on top of it.
What we are building
Our React component will communicate with a Rive ViewModel containing these properties:
| Property | Type | Purpose |
|---|---|---|
emotion |
Enum | Changes the mascot's emotional state |
isTalking |
Boolean | Starts or stops the talking behaviour |
lookX |
Number | Controls horizontal gaze |
lookY |
Number | Controls vertical gaze |
celebrate |
Trigger | Plays a celebration reaction |
The exact property names are not required by Rive. They are simply a clean naming convention that both the animator and developer can agree on.
1. Prepare the Rive file
Before writing React code, the .riv file should contain:
- A character artboard
- Idle and reaction animations
- A State Machine
- A ViewModel connected to the State Machine
- A default ViewModel instance
- Runtime properties such as
emotion,isTalking,lookX, andlookY
A useful character structure might look like this:
Mascot
├── Artboard: Mascot
├── State Machine: Mascot State Machine
└── ViewModel: MascotViewModel
├── emotion
├── isTalking
├── lookX
├── lookY
└── celebrate
Set the ViewModel and its default instance inside the Rive editor. This allows the React runtime to bind to it automatically.
Export the file as:
mascot.riv
2. Add the Rive file to the project
For a React or Next.js project, place the file inside the public directory:
public/
└── mascot.riv
It will then be available from:
/mascot.riv
3. Install the React runtime
Install the Rive React WebGL2 package:
npm install @rive-app/react-webgl2
Or with pnpm:
pnpm add @rive-app/react-webgl2
Or Yarn:
yarn add @rive-app/react-webgl2
The WebGL2 React package provides the useRive hook, the Rive rendering component, and Data Binding hooks.
4. Create the interactive mascot component
Create a new component:
components/InteractiveMascot.tsx
Add the following code:
'use client';
import { useState } from 'react';
import type { PointerEvent } from 'react';
import {
useRive,
useViewModelInstanceBoolean,
useViewModelInstanceEnum,
useViewModelInstanceNumber,
useViewModelInstanceTrigger,
} from '@rive-app/react-webgl2';
import styles from './InteractiveMascot.module.css';
export default function InteractiveMascot() {
const [isTalking, setTalkingState] = useState(false);
const { rive, RiveComponent } = useRive({
src: '/mascot.riv',
artboard: 'Mascot',
stateMachines: 'Mascot State Machine',
autoplay: true,
autoBind: true,
});
const viewModelInstance = rive?.viewModelInstance;
const { value: emotion, setValue: setEmotion } =
useViewModelInstanceEnum('emotion', viewModelInstance);
const { setValue: setIsTalking } =
useViewModelInstanceBoolean('isTalking', viewModelInstance);
const { setValue: setLookX } =
useViewModelInstanceNumber('lookX', viewModelInstance);
const { setValue: setLookY } =
useViewModelInstanceNumber('lookY', viewModelInstance);
const { trigger: celebrate } =
useViewModelInstanceTrigger('celebrate', viewModelInstance);
function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
const bounds = event.currentTarget.getBoundingClientRect();
const relativeX =
(event.clientX - bounds.left) / bounds.width;
const relativeY =
(event.clientY - bounds.top) / bounds.height;
const normalizedX = relativeX * 2 - 1;
const normalizedY = relativeY * 2 - 1;
const clampedX = Math.max(-1, Math.min(1, normalizedX));
const clampedY = Math.max(-1, Math.min(1, normalizedY));
setLookX?.(clampedX);
setLookY?.(clampedY);
}
function resetGaze() {
setLookX?.(0);
setLookY?.(0);
}
function toggleTalking() {
const nextValue = !isTalking;
setTalkingState(nextValue);
setIsTalking?.(nextValue);
}
return (
<section className={styles.section}>
<div
className={styles.mascotContainer}
onPointerMove={handlePointerMove}
onPointerLeave={resetGaze}
aria-label="Interactive animated mascot"
>
<RiveComponent className={styles.riveCanvas} />
</div>
<div className={styles.controls}>
<p>
Current emotion: <strong>{emotion ?? 'Loading'}</strong>
</p>
<div className={styles.buttonGroup}>
<button
type="button"
onClick={() => setEmotion?.('happy')}
>
Happy
</button>
<button
type="button"
onClick={() => setEmotion?.('thinking')}
>
Thinking
</button>
<button
type="button"
onClick={() => setEmotion?.('confused')}
>
Confused
</button>
<button
type="button"
onClick={toggleTalking}
>
{isTalking ? 'Stop Talking' : 'Start Talking'}
</button>
<button
type="button"
onClick={() => celebrate?.()}
>
Celebrate
</button>
</div>
</div>
</section>
);
}
Replace the artboard, State Machine, property, and enum names with the exact names used inside your Rive file.
For example, the following values must exist inside the emotion enum:
happy
thinking
confused
5. Add the component styles
Create:
components/InteractiveMascot.module.css
Add:
.section {
width: 100%;
max-width: 760px;
margin: 0 auto;
padding: 24px;
}
.mascotContainer {
position: relative;
width: 100%;
height: 480px;
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 24px;
background: #f8fafc;
touch-action: none;
}
.riveCanvas {
width: 100%;
height: 100%;
}
.controls {
margin-top: 24px;
}
.buttonGroup {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 12px;
}
.buttonGroup button {
padding: 10px 16px;
border: 1px solid #d1d5db;
border-radius: 999px;
background: #ffffff;
color: #111827;
font: inherit;
cursor: pointer;
}
.buttonGroup button:hover {
background: #f3f4f6;
}
.buttonGroup button:focus-visible {
outline: 3px solid rgba(37, 99, 235, 0.3);
outline-offset: 2px;
}
@media (max-width: 640px) {
.section {
padding: 16px;
}
.mascotContainer {
height: 360px;
}
}
A Rive canvas receives its dimensions from its container. If the animation does not appear, first check that the parent element has an explicit width and height.
6. Use it in a React application
Import the component into your page:
import InteractiveMascot from './components/InteractiveMascot';
export default function App() {
return (
<main>
<h1>Meet our interactive assistant</h1>
<InteractiveMascot />
</main>
);
}
7. Use it in a Next.js App Router project
Rive rendering and pointer interactions require browser-side JavaScript.
That is why the component begins with:
'use client';
The rest of your Next.js page can remain a Server Component.
For example:
import InteractiveMascot from '@/components/InteractiveMascot';
export default function HomePage() {
return (
<main>
<section>
<h1>Your AI learning assistant</h1>
<p>
Ask a question and receive help from an interactive
animated character.
</p>
</section>
<InteractiveMascot />
</main>
);
}
Only the interactive mascot component needs to be a Client Component. You do not need to convert the entire page or layout into one.
How the pointer-controlled gaze works
Browser pointer coordinates are measured in pixels.
Rive character controls are easier to manage using normalized values such as:
-1 = left or top
0 = centre
1 = right or bottom
The pointer position is first calculated relative to the mascot container:
const relativeX =
(event.clientX - bounds.left) / bounds.width;
That returns a value between 0 and 1.
We then convert it to a value between -1 and 1:
const normalizedX = relativeX * 2 - 1;
The result is passed to the character:
setLookX?.(normalizedX);
Inside Rive, lookX and lookY can control eye position, head rotation, body lean, or a mixture of all three.
For a more natural result, avoid moving only the pupils. Add a small amount of head movement and use smoothing inside the Rive State Machine.
Connecting the mascot to application events
The mascot becomes useful when it reacts to real product logic.
Form validation
function handleSubmit(isValid: boolean) {
if (isValid) {
setEmotion?.('happy');
celebrate?.();
} else {
setEmotion?.('confused');
}
}
Loading an AI response
async function askAssistant(question: string) {
setEmotion?.('thinking');
setIsTalking?.(false);
const response = await fetch('/api/assistant', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ question }),
});
if (!response.ok) {
setEmotion?.('error');
throw new Error('The assistant request failed.');
}
const data = await response.json();
setEmotion?.('happy');
setIsTalking?.(true);
return data;
}
Onboarding progress
function handleStepComplete(step: number) {
if (step === 3) {
celebrate?.();
setEmotion?.('happy');
}
}
Authentication errors
function handleLoginError() {
setEmotion?.('concerned');
}
The animation system does not need to understand your complete application. It only needs a clear set of properties that map product events to character behaviours.
State Machine inputs or Data Binding?
Older Rive projects often expose Boolean, Number, and Trigger inputs directly from a State Machine.
Those inputs can still exist in legacy projects. However, Rive now recommends Data Binding and ViewModels for new implementations.
Data Binding provides a more structured contract between the .riv file and application code.
Instead of searching for several unrelated inputs, developers can work with a defined model:
MascotViewModel
├── emotion
├── isTalking
├── lookX
├── lookY
└── celebrate
It also allows properties such as:
- Strings
- Colors
- Enums
- Images
- Nested ViewModels
- Lists
- Artboards
- Triggers
- Numbers
- Booleans
This becomes especially useful when the same mascot needs different names, themes, outfits, expressions, or user-specific data.
Structuring a production-ready mascot
A scalable mascot system should not contain dozens of disconnected animations controlled by unclear input names.
A better structure is to separate the character into behavioural groups.
Core states
idle
listening
thinking
talking
success
error
sleeping
Emotional states
neutral
happy
excited
confused
concerned
sad
surprised
Continuous controls
lookX
lookY
talkIntensity
energy
progress
One-time reactions
celebrate
wave
blink
notice
correct
incorrect
The animator and developer should agree on this contract before integration begins.
A small integration document can prevent many problems later:
Property: emotion
Type: Enum
Values: neutral, happy, thinking, confused, error
Property: isTalking
Type: Boolean
Default: false
Property: lookX
Type: Number
Range: -1 to 1
Default: 0
Property: lookY
Type: Number
Range: -1 to 1
Default: 0
Property: celebrate
Type: Trigger
Common integration problems
The mascot is not visible
Check that the parent container has a defined height:
.mascotContainer {
width: 100%;
height: 480px;
}
A canvas inside a container with no height may appear as if it failed to load.
The ViewModel properties are unavailable
Confirm that:
- The artboard has a ViewModel assigned
- A default ViewModel instance exists
-
autoBind: trueis enabled - The property names exactly match the Rive file
- The correct artboard and State Machine are loading
Property names are case-sensitive.
For example:
isTalking
is not the same as:
istalking
The character animation restarts unexpectedly
Keep the useRive hook and the returned RiveComponent together inside a dedicated wrapper component.
Avoid frequently unmounting and remounting the canvas or changing its React key.
Pointer tracking works on desktop but not mobile
Use Pointer Events rather than mouse-only events.
This works across mouse, pen, and touch input:
onPointerMove={handlePointerMove}
However, mobile devices do not have a persistent cursor. For mobile interfaces, consider controlling gaze using:
- Touch position
- Device orientation
- Face tracking
- UI element location
- App-provided
lookXandlookYvalues
Do not design a critical character behaviour that depends only on desktop hover.
The mascot keeps talking forever
Do not connect talking to a permanent looping animation without application control.
Use an explicit Boolean:
setIsTalking?.(true);
Then stop it when audio playback finishes:
audio.addEventListener('ended', () => {
setIsTalking?.(false);
});
For accurate speech animation, use viseme timing rather than a simple random mouth loop.
Performance recommendations
Keep Rive isolated in its own component
This prevents unrelated React state changes from unnecessarily recreating the Rive canvas.
Avoid unmounting the canvas repeatedly
Show and hide the surrounding section when possible instead of constantly destroying and recreating the component.
Keep the .riv file focused
Remove unused assets, unnecessary paths, hidden artboards, and animations that are not required at runtime.
Avoid permanently active animations
An idle state can contain occasional blinking and breathing without requiring every layer to animate continuously.
Test on real devices
A mascot that performs well on a desktop computer may behave differently on lower-powered mobile devices.
Test:
- Loading time
- Canvas resolution
- Memory usage
- Multiple mascot instances
- Touch interactions
- Long-running State Machines
Accessibility considerations
An animated mascot should support the interface, not become the only way to understand it.
Make sure that:
- Important messages are also displayed as text
- Controls are keyboard accessible
- The mascot does not block form fields
- Fast flashing effects are avoided
- Sound does not autoplay unexpectedly
- Reduced-motion preferences are respected
- The product remains usable if the animation fails
The mascot can improve personality and guidance, but core product functionality should never depend entirely on animation.
Taking the mascot further
Once the basic integration is working, the same system can support:
- AI assistant states
- Text-to-speech lip sync
- Viseme-based mouth animation
- Runtime theme colors
- Dynamic character names
- Different outfits or accessories
- Progress-based reactions
- User achievements
- Notification reactions
- Scroll-driven behaviour
- Multiple character personalities
- Context-aware emotional responses
The strongest interactive mascots are designed as product systems from the beginning.
Animation quality matters, but the runtime architecture, property naming, transitions, developer handoff, and application integration matter just as much.
Need an interactive mascot for your product?
Mascot Engine designs and builds production-ready interactive Rive mascots for AI products, SaaS platforms, mobile apps, and web experiences.
The work can include:
- Character preparation and rigging
- Emotional animations
- Interactive State Machines
- Data Binding and ViewModels
- Pointer and touch interactions
- Mobile-ready gaze controls
- AI listening, thinking, and talking states
- Viseme lip sync
- Flutter, React Native, and web preparation
- Developer-ready input documentation
Instead of delivering only a looping animation, Mascot Engine creates a character system that can connect to real product logic.
Start an interactive mascot project with Mascot Engine.
Official resources
- Rive React runtime documentation
- Rive React Data Binding documentation
- Rive React best practices
- Next.js Client Components documentation
About the author
Praneeth Kawya Thathsara is a Rive Animator and Interactive Product Designer specializing in production-ready mascot systems for AI, SaaS, mobile, and web products. He is the founder of Mascot Engine.
Top comments (0)