Skip to main content

Guide

Lottie vs Rive vs CSS Animations 2026

Lottie, Rive, and CSS animations compared for web apps. After Effects handoff, state machines, GPU rendering — which animation format fits your project in 2026.

·PkgPulse Team·
0

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)

Lottie File Format Evolution: JSON vs .lottie

Lottie animations can be delivered in two formats — the original .json format and the newer .lottie container format — and the difference has meaningful implications for performance in production.

The original Lottie JSON format embeds all animation data, including any raster images, as base64-encoded strings within the JSON file. A complex animation with background textures or photo-realistic elements can easily reach 500KB-2MB as a JSON file. The JSON must be parsed in its entirety before rendering can begin, and large base64-encoded images within the JSON are inefficient for both parsing and memory use.

The .lottie container format (developed by LottieFiles) is a ZIP-compressed package that separates the animation data JSON from any associated assets. Compression alone typically reduces file size by 70-80% compared to the equivalent JSON. The assets are referenced by path rather than embedded, allowing the runtime to load them on demand. The @lottiefiles/dotlottie-react and @lottiefiles/dotlottie-web packages use a WASM-based renderer that can parse .lottie files 2-3x faster than the equivalent JSON. For production applications, migrating to .lottie format is a free performance win that requires only changing the file source and using the dotlottie player instead of lottie-web.

The dotlottie format also introduces multi-animation containers — a single .lottie file can contain multiple animations (for example, all icon animation variants in a design system) with shared asset deduplication. This reduces the total payload compared to loading multiple separate files.

Rive's State Machine Architecture and Designer-Developer Workflow

Rive's state machine system is architecturally different from traditional animation tools, and understanding this difference is key to knowing when Rive is the right choice over Lottie.

In Lottie, animation logic lives in code: you play animation A on hover, pause on mouse-leave, play animation B on click. The animation file is passive — it just describes shapes and keyframes. In Rive, the state machine logic lives in the .riv file itself. A designer can define states (idle, hover, pressed, loading, success, error), transitions between states (trigger on input change, blend over 200ms), and the conditions that activate transitions — all in the Rive editor without any code. The developer's job is reduced to wiring up React state or event handlers to state machine inputs.

This designer-developer separation is Rive's most powerful feature for teams where designers and developers work in parallel. A designer can update the state machine logic, add new states, or refine transition timing and re-export the .riv file without any code changes from the developer. As long as the state machine input names don't change (like a public API), the new file is a drop-in replacement. This is a genuinely different development model compared to Lottie (where animation logic is in code) or CSS animations (where it's split across CSS and JS).

The WASM renderer that powers Rive comes with a ~200KB bundle size overhead compared to zero for CSS animations or ~50KB for dotlottie. For applications that have one or two interactive animations, this overhead is significant. For applications that invest heavily in Rive animations — character-driven onboarding flows, interactive product demos, game-like UI — the per-animation incremental cost is small and the state machine capability pays for itself in development efficiency.

Performance Comparison: Composite Layers and GPU Acceleration

The rendering performance of all three animation approaches depends critically on which CSS properties change during animation and whether the browser can use GPU compositing.

CSS animations that only modify transform and opacity are the fastest possible animations on the web. These two properties are composited by the browser's GPU without involving the CPU at all — the browser extracts the animated element to its own GPU layer and updates it in the compositor thread, completely bypassing the main thread. A 60fps CSS spin animation uses essentially zero CPU. Animations that modify width, height, top, left, or any property that triggers layout reflow are dramatically slower and can cause jank even on powerful hardware.

Lottie's SVG renderer interpolates vector paths, which triggers layout and paint recalculation in the browser's rendering pipeline. For simple icon animations this is acceptable, but for complex multi-layer animations with many simultaneously moving vector elements, the per-frame SVG manipulation can consume significant CPU. Lottie's Canvas renderer avoids SVG's DOM overhead by drawing directly to a <canvas> element, which is faster but loses the resolution independence of SVG. The .lottie format with the dotlottie WASM renderer uses canvas rendering by default.

Rive renders exclusively through Canvas or WebGL using its WASM runtime, which bypasses the browser's layout engine entirely. Complex mesh deformations and blend state animations that would be prohibitively expensive in SVG are handled efficiently because Rive's renderer controls the entire paint pipeline. For GPU-intensive animations — particle systems, fluid motion, complex skeletal rigs — Rive's approach is the most performant of the three, even accounting for the WASM startup overhead.

Accessibility: Respecting Reduced Motion

All three animation approaches should honor the prefers-reduced-motion media query. Users who have set this OS preference — often due to vestibular disorders or motion sensitivity — will experience jarring, inaccessible interfaces if animations play unconditionally. For CSS animations, wrapping animation declarations in @media (prefers-reduced-motion: no-preference) ensures animations only run for users who haven't opted out. For Lottie and Rive, check the preference with window.matchMedia("(prefers-reduced-motion: reduce)").matches and skip autoplay or set speed to zero accordingly. This is not optional polish — it's a WCAG 2.1 requirement that affects a meaningful percentage of users.

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 →

See also: AVA vs Jest and Mermaid vs D3.js vs Chart.js 2026, Cytoscape.js vs vis-network vs Sigma.js.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.