DEV Community

Cover image for How to Integrate an Interactive Rive Mascot into a React or Next.js Web App
Mascot Engine
Mascot Engine

Posted on

How to Integrate an Interactive Rive Mascot into a React or Next.js Web App

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. A character artboard
  2. Idle and reaction animations
  3. A State Machine
  4. A ViewModel connected to the State Machine
  5. A default ViewModel instance
  6. Runtime properties such as emotion, isTalking, lookX, and lookY

A useful character structure might look like this:

Mascot
├── Artboard: Mascot
├── State Machine: Mascot State Machine
└── ViewModel: MascotViewModel
    ├── emotion
    ├── isTalking
    ├── lookX
    ├── lookY
    └── celebrate
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It will then be available from:

/mascot.riv
Enter fullscreen mode Exit fullscreen mode

3. Install the React runtime

Install the Rive React WebGL2 package:

npm install @rive-app/react-webgl2
Enter fullscreen mode Exit fullscreen mode

Or with pnpm:

pnpm add @rive-app/react-webgl2
Enter fullscreen mode Exit fullscreen mode

Or Yarn:

yarn add @rive-app/react-webgl2
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

5. Add the component styles

Create:

components/InteractiveMascot.module.css
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The pointer position is first calculated relative to the mascot container:

const relativeX =
  (event.clientX - bounds.left) / bounds.width;
Enter fullscreen mode Exit fullscreen mode

That returns a value between 0 and 1.

We then convert it to a value between -1 and 1:

const normalizedX = relativeX * 2 - 1;
Enter fullscreen mode Exit fullscreen mode

The result is passed to the character:

setLookX?.(normalizedX);
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Onboarding progress

function handleStepComplete(step: number) {
  if (step === 3) {
    celebrate?.();
    setEmotion?.('happy');
  }
}
Enter fullscreen mode Exit fullscreen mode

Authentication errors

function handleLoginError() {
  setEmotion?.('concerned');
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Emotional states

neutral
happy
excited
confused
concerned
sad
surprised
Enter fullscreen mode Exit fullscreen mode

Continuous controls

lookX
lookY
talkIntensity
energy
progress
Enter fullscreen mode Exit fullscreen mode

One-time reactions

celebrate
wave
blink
notice
correct
incorrect
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Common integration problems

The mascot is not visible

Check that the parent container has a defined height:

.mascotContainer {
  width: 100%;
  height: 480px;
}
Enter fullscreen mode Exit fullscreen mode

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: true is 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
Enter fullscreen mode Exit fullscreen mode

is not the same as:

istalking
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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 lookX and lookY values

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);
Enter fullscreen mode Exit fullscreen mode

Then stop it when audio playback finishes:

audio.addEventListener('ended', () => {
  setIsTalking?.(false);
});
Enter fullscreen mode Exit fullscreen mode

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


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)