Limited offer — lifetime access:$99$79
Articlereactriveanimated iconstutorialstate machine

Rive Animation in React — State Machines, Hover Triggers & More

How to use Rive animations in React with @rive-app/react-canvas. Covers useRive hook, state machine inputs, hover and click triggers, and common pitfalls.

Rive is a real-time animation format built for apps and games. Unlike Lottie (which plays a pre-baked timeline), Rive animations have state machines — graph-based logic that responds to inputs like booleans, numbers, and triggers. This makes Rive ideal for interactive icons: hover states, click animations, loading-to-success transitions.

This guide covers the @rive-app/react-canvas package for React.

Download .riv files at Unicorn Icons — every icon ships with a native Rive file.

How Rive state machines work

A Rive file (.riv) contains one or more artboards, each with optional state machines. State machines have:

  • States — animation clips (e.g., "idle", "hover", "active")
  • Transitions — rules for moving between states
  • Inputs — external values that trigger transitions:
    • Boolean — true/false (e.g., "is hovered")
    • Number — numeric values (e.g., "progress")
    • Trigger — one-shot event (e.g., "clicked")

In React, you read and set these inputs via hooks. The runtime handles the state transitions and rendering.

All Unicorn Icons .riv files include a "State Machine 1" with a Hover boolean input by default.

Setup

1. Install

bash
copy
npm install @rive-app/react-canvas
# or
yarn add @rive-app/react-canvas

2. Basic usage — looping animation

tsx
copy
"use client";

import { useRive } from "@rive-app/react-canvas";

export default function ArrowIcon() {
  const { RiveComponent } = useRive({
    src: "/icons/arrow.riv",
    autoplay: true,
  });

  return <RiveComponent style={{ width: 32, height: 32 }} />;
}

RiveComponent is a canvas element. Always set an explicit size — the canvas needs dimensions.

3. With a state machine

tsx
copy
"use client";

import { useRive, Layout, Fit, Alignment } from "@rive-app/react-canvas";

export default function ArrowIcon() {
  const { RiveComponent } = useRive({
    src: "/icons/arrow.riv",
    stateMachines: "State Machine 1",
    autoplay: true,
    layout: new Layout({
      fit: Fit.Contain,
      alignment: Alignment.Center,
    }),
  });

  return <RiveComponent style={{ width: 32, height: 32 }} />;
}

Layout controls how the animation fits the canvas. Fit.Contain (default) scales to fit without cropping.


Interactive inputs

Boolean input — hover trigger

tsx
copy
"use client";

import { useRive, useStateMachineInput } from "@rive-app/react-canvas";

export default function HoverIcon() {
  const { RiveComponent, rive } = useRive({
    src: "/icons/arrow.riv",
    stateMachines: "State Machine 1",
    autoplay: true,
  });

  const hoverInput = useStateMachineInput(rive, "State Machine 1", "Hover");

  return (
    <button
      onMouseEnter={() => { if (hoverInput) hoverInput.value = true; }}
      onMouseLeave={() => { if (hoverInput) hoverInput.value = false; }}
      style={{ background: "none", border: "none", padding: 0, cursor: "pointer" }}
    >
      <RiveComponent style={{ width: 32, height: 32 }} />
    </button>
  );
}

useStateMachineInput returns null until the Rive runtime loads — always guard with if (hoverInput).

Trigger input — click animation

tsx
copy
"use client";

import { useRive, useStateMachineInput } from "@rive-app/react-canvas";

export default function ClickIcon() {
  const { RiveComponent, rive } = useRive({
    src: "/icons/check.riv",
    stateMachines: "State Machine 1",
    autoplay: true,
  });

  const clickTrigger = useStateMachineInput(rive, "State Machine 1", "Click");

  return (
    <button onClick={() => clickTrigger?.fire()}>
      <RiveComponent style={{ width: 32, height: 32 }} />
    </button>
  );
}

Trigger inputs expose .fire() instead of .value.

Number input — progress

tsx
copy
const progressInput = useStateMachineInput(rive, "State Machine 1", "Progress");

// Set to a value between 0 and 100
progressInput.value = 75;

Reusable RiveIcon component

tsx
copy
"use client";

import { useRive, useStateMachineInput, Layout, Fit, Alignment } from "@rive-app/react-canvas";

interface RiveIconProps {
  src: string;
  size?: number;
  stateMachine?: string;
  hoverInput?: string;
  className?: string;
}

export default function RiveIcon({
  src,
  size = 32,
  stateMachine = "State Machine 1",
  hoverInput = "Hover",
  className,
}: RiveIconProps) {
  const { RiveComponent, rive } = useRive({
    src,
    stateMachines: stateMachine,
    autoplay: true,
    layout: new Layout({ fit: Fit.Contain, alignment: Alignment.Center }),
  });

  const hover = useStateMachineInput(rive, stateMachine, hoverInput);

  return (
    <div
      className={className}
      style={{ width: size, height: size, cursor: "pointer" }}
      onMouseEnter={() => { if (hover) hover.value = true; }}
      onMouseLeave={() => { if (hover) hover.value = false; }}
    >
      <RiveComponent />
    </div>
  );
}

Usage:

tsx
copy
<RiveIcon src="/icons/arrow.riv" size={24} />
<RiveIcon src="/icons/check.riv" size={32} />

Common pitfalls

Pitfall 1: rive is null on first render

rive from useRive is null until the file loads. useStateMachineInput returns null in this state. Always use optional chaining or null-check before accessing inputs.

tsx
copy
// ❌ Crashes on mount
hoverInput.value = true;

// ✅
if (hoverInput) hoverInput.value = true;
// or
hoverInput?.value = true; // Note: optional chaining on assignment isn't valid TS

Pitfall 2: Mismatched state machine or input name

The strings "State Machine 1" and "Hover" must exactly match what's defined in the Rive file. Check these in the Rive editor. Unicorn Icons files use "State Machine 1" / "Hover" consistently.

Pitfall 3: SSR in Next.js

@rive-app/react-canvas uses canvas — it can't render server-side. Add "use client" to any component that uses it, or use dynamic():

tsx
copy
import dynamic from "next/dynamic";
const RiveIcon = dynamic(() => import("@/components/RiveIcon"), { ssr: false });

See the Next.js animated icons guide.

Pitfall 4: Canvas not filling its container

The Rive canvas may not fill the parent div. Fix with explicit dimensions on both the wrapper and RiveComponent:

tsx
copy
<div style={{ width: 32, height: 32 }}>
  <RiveComponent style={{ width: "100%", height: "100%" }} />
</div>

Rive vs Lottie — when to use each

ScenarioRiveLottie
Hover/click interactive icon✓ BestLimited
Simple looping icon
File size matters✓ SmallerLarger
After Effects workflow
iOS / Android native
Browser supportAll modernAll modern

For a deep comparison, see Rive vs Lottie — which format to choose.


Next steps