Skip to main content

Lottie vs Rive vs CSS Animations: Web Animation Formats (2026)

·PkgPulse Team

TL;DR

Lottie plays After Effects animations on the web — designers export from AE with the Bodymovin plugin, developers render with lottie-web or @lottiefiles/dotlottie-web, vector animations, widely adopted. Rive is the interactive animation runtime — state machines, blend states, mesh deformation, GPU-accelerated, real-time interaction, designed for developers. CSS Animations are browser-native — keyframes, transitions, GPU-composited, no dependencies, best for UI micro-interactions. In 2026: Lottie for designer-created animations (icons, illustrations), Rive for interactive stateful animations, CSS animations for UI transitions and micro-interactions.

Key Takeaways

  • Lottie: lottie-web ~300K weekly downloads — After Effects export, vector, JSON/.lottie
  • Rive: @rive-app/canvas ~50K weekly downloads — state machines, GPU, interactive
  • CSS Animations: Built into browsers — no dependencies, GPU-composited, performant
  • Lottie is the industry standard for designer → developer animation handoff
  • Rive enables interactivity without code (state machines in the editor)
  • CSS animations are the most performant for simple UI animations

Lottie

Lottie — After Effects animations for web:

Basic player

<!-- dotLottie player (modern): -->
<script src="https://unpkg.com/@lottiefiles/dotlottie-wc@latest/dist/dotlottie-wc.js"
        type="module"></script>

<dotlottie-wc
  src="https://lottie.host/animation.lottie"
  autoplay
  loop
  style="width: 300px; height: 300px">
</dotlottie-wc>

React integration

import { DotLottieReact } from "@lottiefiles/dotlottie-react"
import { useState } from "react"

function AnimatedIcon() {
  return (
    <DotLottieReact
      src="/animations/success-check.lottie"
      autoplay
      loop={false}
      style={{ width: 120, height: 120 }}
    />
  )
}

// With controls:
function ControlledAnimation() {
  const [dotLottie, setDotLottie] = useState(null)

  return (
    <div>
      <DotLottieReact
        src="/animations/loading.lottie"
        loop
        autoplay
        dotLottieRefCallback={setDotLottie}
      />

      <button onClick={() => dotLottie?.play()}>Play</button>
      <button onClick={() => dotLottie?.pause()}>Pause</button>
      <button onClick={() => dotLottie?.stop()}>Stop</button>
      <button onClick={() => dotLottie?.setSpeed(2)}>2x Speed</button>
    </div>
  )
}

lottie-web (classic)

import lottie from "lottie-web"

// Load animation:
const anim = lottie.loadAnimation({
  container: document.getElementById("animation")!,
  renderer: "svg",          // "svg" | "canvas" | "html"
  loop: true,
  autoplay: true,
  path: "/animations/hero.json",  // JSON animation file
})

// Controls:
anim.play()
anim.pause()
anim.stop()
anim.setSpeed(1.5)
anim.setDirection(-1)       // Reverse
anim.goToAndStop(30, true)  // Go to frame 30

// Events:
anim.addEventListener("complete", () => {
  console.log("Animation complete")
})

anim.addEventListener("loopComplete", () => {
  console.log("Loop finished")
})

// Destroy:
anim.destroy()

Interactivity (scroll-based)

import { DotLottieReact } from "@lottiefiles/dotlottie-react"
import { useEffect, useRef, useState } from "react"

function ScrollAnimation() {
  const [dotLottie, setDotLottie] = useState(null)
  const containerRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!dotLottie || !containerRef.current) return

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          dotLottie.play()
        } else {
          dotLottie.pause()
        }
      },
      { threshold: 0.5 }
    )

    observer.observe(containerRef.current)
    return () => observer.disconnect()
  }, [dotLottie])

  return (
    <div ref={containerRef}>
      <DotLottieReact
        src="/animations/scroll-reveal.lottie"
        loop={false}
        dotLottieRefCallback={setDotLottie}
      />
    </div>
  )
}

Rive

Rive — interactive animation runtime:

Basic setup

<!-- Rive canvas: -->
<script src="https://unpkg.com/@rive-app/canvas@latest"></script>

<canvas id="rive-canvas" width="500" height="500"></canvas>

<script>
  const rive = new RiveCanvas.Rive({
    src: "/animations/character.riv",
    canvas: document.getElementById("rive-canvas"),
    autoplay: true,
    stateMachines: "main",  // State machine name
    onLoad: () => {
      rive.resizeDrawingSurfaceToCanvas()
    },
  })
</script>

React integration

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

function InteractiveButton() {
  const { rive, RiveComponent } = useRive({
    src: "/animations/button.riv",
    stateMachines: "button_state",
    autoplay: true,
  })

  // Connect to state machine inputs:
  const hoverInput = useStateMachineInput(rive, "button_state", "isHovered")
  const clickInput = useStateMachineInput(rive, "button_state", "isPressed")

  return (
    <RiveComponent
      style={{ width: 200, height: 60 }}
      onMouseEnter={() => hoverInput && (hoverInput.value = true)}
      onMouseLeave={() => hoverInput && (hoverInput.value = false)}
      onMouseDown={() => clickInput?.fire()}
    />
  )
}

State machines

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

// Interactive toggle with state machine:
function AnimatedToggle({ onChange }: { onChange: (on: boolean) => void }) {
  const { rive, RiveComponent } = useRive({
    src: "/animations/toggle.riv",
    stateMachines: "toggle_sm",
    autoplay: true,
  })

  const isOn = useStateMachineInput(rive, "toggle_sm", "isOn")

  const handleClick = () => {
    if (isOn) {
      isOn.value = !isOn.value
      onChange(isOn.value)
    }
  }

  return (
    <RiveComponent
      onClick={handleClick}
      style={{ width: 80, height: 40, cursor: "pointer" }}
    />
  )
}

// Character with multiple states:
function GameCharacter() {
  const { rive, RiveComponent } = useRive({
    src: "/animations/character.riv",
    stateMachines: "character_sm",
    autoplay: true,
  })

  const speed = useStateMachineInput(rive, "character_sm", "speed")
  const isJumping = useStateMachineInput(rive, "character_sm", "jump")
  const health = useStateMachineInput(rive, "character_sm", "health")

  return (
    <div>
      <RiveComponent style={{ width: 300, height: 300 }} />
      <input
        type="range" min="0" max="100"
        onChange={(e) => speed && (speed.value = +e.target.value)}
      />
      <button onClick={() => isJumping?.fire()}>Jump</button>
      <input
        type="range" min="0" max="100"
        onChange={(e) => health && (health.value = +e.target.value)}
      />
    </div>
  )
}

Events and callbacks

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

function AnimationWithEvents() {
  const { RiveComponent } = useRive({
    src: "/animations/onboarding.riv",
    stateMachines: "flow",
    autoplay: true,
    onStateChange: (event) => {
      console.log("State changed:", event.data)
    },
  })

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

CSS Animations

Browser-native — keyframes and transitions:

Keyframe animations

/* Fade in: */
@keyframes fadeIn {
  from { opacity: 0; transform: translateY(20px); }
  to { opacity: 1; transform: translateY(0); }
}

.fade-in {
  animation: fadeIn 0.3s ease-out forwards;
}

/* Pulse: */
@keyframes pulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.05); }
}

.pulse {
  animation: pulse 2s ease-in-out infinite;
}

/* Spin: */
@keyframes spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

.spinner {
  animation: spin 1s linear infinite;
  width: 24px;
  height: 24px;
  border: 3px solid #e5e7eb;
  border-top-color: #3b82f6;
  border-radius: 50%;
}

Transitions

/* Smooth hover: */
.button {
  background: #3b82f6;
  color: white;
  padding: 12px 24px;
  border-radius: 6px;
  transition: all 0.2s ease;
}

.button:hover {
  background: #2563eb;
  transform: translateY(-1px);
  box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}

/* Accordion expand: */
.accordion-content {
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.3s ease-out;
}

.accordion-content.open {
  max-height: 500px;
}

/* Slide in sidebar: */
.sidebar {
  transform: translateX(-100%);
  transition: transform 0.3s ease;
}

.sidebar.open {
  transform: translateX(0);
}

React with CSS animations

import { useState } from "react"
import styles from "./Toast.module.css"

function Toast({ message, onClose }: { message: string; onClose: () => void }) {
  const [exiting, setExiting] = useState(false)

  const handleClose = () => {
    setExiting(true)
    setTimeout(onClose, 300) // Match animation duration
  }

  return (
    <div className={`${styles.toast} ${exiting ? styles.exit : styles.enter}`}>
      <span>{message}</span>
      <button onClick={handleClose}>×</button>
    </div>
  )
}

/* Toast.module.css */
/*
.toast {
  position: fixed;
  bottom: 20px;
  right: 20px;
  background: #1a1a1a;
  color: white;
  padding: 12px 20px;
  border-radius: 8px;
}

.enter {
  animation: slideIn 0.3s ease-out forwards;
}

.exit {
  animation: slideOut 0.3s ease-in forwards;
}

@keyframes slideIn {
  from { transform: translateX(120%); opacity: 0; }
  to { transform: translateX(0); opacity: 1; }
}

@keyframes slideOut {
  from { transform: translateX(0); opacity: 1; }
  to { transform: translateX(120%); opacity: 0; }
}
*/

Modern CSS features

/* View Transitions API (Chrome 111+): */
::view-transition-old(root) {
  animation: fade-out 0.3s ease;
}

::view-transition-new(root) {
  animation: fade-in 0.3s ease;
}

/* Scroll-driven animations (Chrome 115+): */
@keyframes reveal {
  from { opacity: 0; transform: translateY(50px); }
  to { opacity: 1; transform: translateY(0); }
}

.scroll-reveal {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}

/* Container queries + animation: */
@container (min-width: 600px) {
  .card {
    animation: expandCard 0.3s ease forwards;
  }
}

Feature Comparison

FeatureLottieRiveCSS Animations
SourceAfter EffectsRive editorCode
File formatJSON / .lottie.riv (binary)CSS/JS
RendererSVG / CanvasCanvas (WASM)Browser
Bundle size~50KB (dotlottie)~200KB (WASM)0KB
State machines
InteractivityBasic (play/pause)✅ (inputs, events)Hover/focus/active
Mesh deformation
Design toolAfter EffectsRive (free)DevTools
VectorN/A
PerformanceGoodExcellent (GPU)Excellent (composited)
React bindingsNative (className)
Learning curveLow (devs)MediumLow
Designer handoff✅ (Bodymovin)✅ (Rive editor)❌ (manual)
AdoptionIndustry standardGrowingUniversal

When to Use Each

Use Lottie if:

  • Designers create animations in After Effects
  • Need vector animations (icons, illustrations, onboarding)
  • Want the industry-standard designer → developer handoff
  • Building marketing pages or app micro-interactions

Use Rive if:

  • Need interactive animations with state machines
  • Want GPU-accelerated rendering for complex animations
  • Building game-like UI with blend states and mesh deformation
  • Need real-time user interaction (hover, click, drag affects animation)

Use CSS Animations if:

  • Building UI transitions (hover, focus, enter/exit)
  • Want zero-dependency, maximum performance
  • Need simple animations (fade, slide, scale, rotate)
  • Performance is critical (GPU-composited transforms)

Methodology

Feature comparison based on @lottiefiles/dotlottie-react v0.x, @rive-app/react-canvas v4.x, and CSS Animations Level 2 specification as of March 2026.

Compare animation and frontend libraries on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.