iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🎬

Exploring the New Animation API in Remix v3.0.0-alpha.2

に公開
2026/07/13

What is this?

I have incorporated the three animation APIs (animate, spring, and tween) added in Remix v3.0.0-alpha.2, released on January 28, 2025, into the task management app I created previously.

For the official documentation, please refer to the @remix-run/component README.

You can try the demo for this post here:

https://remix-task-manager-eight.vercel.app/

Task addition demo

Design Philosophy Based on Web Standards

The animation APIs in this library are built with the design philosophy of wrapping web standard technologies in a thin layer. It does not have its own proprietary animation engine and makes maximum use of native browser capabilities.

API Web Standards Used Internally
animate (enter/exit) Web Animations API
animate (layout) FLIP technique + requestAnimationFrame
spring CSS Transitions
tween requestAnimationFrame + Generator

It is lightweight, has no unnecessary runtime overhead, and benefits from browser optimizations. Being able to inspect it normally in DevTools is also a great point.


animate - Declarative Animations

This API allows you to declaratively describe the entry/exit of elements and layout changes. enter defines the animation when an element is added to the DOM, exit for when it is removed, and layout for when its position or size changes.

<li
  animate={{
    enter: {
      opacity: 0,
      transform: 'translateX(-20px)',
      duration: 150,
      easing: 'ease-out',
    },
  }}
>
  {task.title}
</li>

Icon Switching with enter / exit

For the theme toggle button, I implemented icon swap animations by combining enter and exit.

const iconAnimation = {
  enter: {
    transform: 'translateY(-12px) scale(0.7)',
    filter: 'blur(3px)',
    opacity: 0,
    duration: 250,
    easing: 'ease-out',
  },
  exit: {
    transform: 'translateY(12px) scale(0.7)',
    filter: 'blur(3px)',
    opacity: 0,
    duration: 200,
    easing: 'ease-in',
  },
}

{theme.value === 'light' ? (
  <span key="moon" class="absolute" animate={iconAnimation}>🌙</span>
) : (
  <span key="sun" class="absolute" animate={iconAnimation}>☀️</span>
)}

By adding a key, the animation is triggered when elements are swapped via conditional logic.

Theme toggle

layout Animations

When layout is specified, the element animates smoothly using the FLIP technique whenever its position or size changes.

<div
  key="highlight"
  class="absolute inset-y-1 rounded-md bg-white"
  style={{
    width: 'calc((100% - 0.5rem) / 3)',
    left: `calc(${FILTERS.indexOf(filter)} * (100% - 0.5rem) / 3 + 0.25rem)`,
  }}
  animate={{ layout: { ...spring('snappy') } }}
/>

The background highlight of the filter buttons slides according to the selection.

Filter switch


spring - Physics-based Animations

It generates CSS transitions based on the laws of physics for springs. It features natural and pleasant movement.

There are three presets available: smooth for relaxed movement suitable for page transitions or modals, snappy for crisp movement suited for buttons or tab switching, and bouncy for springy movement for emphasis or success feedback.

You can use spring.transition() to generate a spring-based CSS transition string for specified properties.

<input
  style={{
    transition: spring.transition(['transform', 'box-shadow'], 'snappy'),
    transform: isFocused ? 'scale(1.02)' : 'scale(1)',
    boxShadow: isFocused
      ? '0 4px 12px rgba(59, 130, 246, 0.25)'
      : '0 0 0 transparent',
  }}
/>

Input focus

I applied the bouncy preset to the checkbox.

<input
  type="checkbox"
  style={{
    transition: spring.transition('transform', 'bouncy'),
    transform: task.completed ? 'scale(1.2)' : 'scale(1)',
  }}
/>

Checkbox


tween - Imperative Animations

This is a Generator-based imperative animation API. Use it when you need finer control.

import { tween, easings } from '@remix-run/component'

Counter Animation

The numbers animate when the remaining task count changes.

const animateCount = (from: number, to: number) => {
  const animation = tween({
    from,
    to,
    duration: 200,
    curve: easings.easeOut,
  })

  animation.next()

  const tick = (timestamp: number) => {
    if (handle.signal.aborted) return

    const result = animation.next(timestamp)
    displayCount = Math.round(result.value)
    handle.update()

    if (!result.done) {
      requestAnimationFrame(tick)
    }
  }

  requestAnimationFrame(tick)
}

By checking handle.signal.aborted, you can safely interrupt the animation if the component is unmounted.

Deletion Animation

When deleting a task, the actual deletion logic is called after the animation finishes.

const handleDelete = (id: number, onDelete: Props['onDelete']) => {
  const animation = tween({
    from: 0,
    to: 1,
    duration: 150,
    curve: easings.easeIn,
  })

  animation.next()

  const tick = (timestamp: number) => {
    if (handle.signal.aborted) return

    const result = animation.next(timestamp)
    rowEl.style.opacity = String(1 - result.value)
    rowEl.style.transform = `translateX(${result.value * 30}px)`

    if (!result.done) {
      requestAnimationFrame(tick)
    } else {
      onDelete(id)
    }
  }

  requestAnimationFrame(tick)
}

Task deletion

Since tween tends to increase boilerplate, it is recommended to wrap it in helper functions. Refer to the repository implementation for more details.


Usage Guide

API Use Case Examples
animate Element entry/exit, layout changes List item add/remove, tab switching
spring CSS transition enhancements Hover effects, focus states, scale changes
tween Value animations, complex control Counters, progress bars, custom deletion effects

It is recommended to use animate and spring declaratively as a base, and reach for tween only when fine-grained control is necessary.


Internal Implementation Overview

Let's take a closer look at how each API leverages web standards.

How animate (enter/exit) Works

It uses the Web Animations API internally.

// Conceptual internal implementation
function animateEnter(element: HTMLElement, config: EnterConfig) {
  const { opacity, transform, duration, easing } = config

  // Set initial state
  const keyframes = [
    { opacity, transform },           // from (specified values)
    { opacity: 1, transform: 'none' } // to (normal state)
  ]

  // Call Web Animations API
  element.animate(keyframes, {
    duration,
    easing,
    fill: 'forwards',
  })
}

For exit, it runs the animation in reverse and removes the element from the DOM upon completion. By using the Web Animations API, you get performance equivalent to CSS animations.

How animate (layout) Works - FLIP Technique

Layout animations use the FLIP (First, Last, Invert, Play) technique.

// The flow of the FLIP technique
function animateLayout(element: HTMLElement, springConfig: SpringConfig) {
  // 1. First: Record current position
  const first = element.getBoundingClientRect()

  // 2. Last: Get position after DOM update (actually after rendering)
  const last = element.getBoundingClientRect()

  // 3. Invert: Calculate the delta and apply inverse transformation
  const deltaX = first.left - last.left
  const deltaY = first.top - last.top
  element.style.transform = `translate(${deltaX}px, ${deltaY}px)`

  // 4. Play: Remove transform and animate
  requestAnimationFrame(() => {
    element.style.transition = generateSpringTransition(springConfig)
    element.style.transform = 'none'
  })
}

This enables higher-performance layout animations compared to animating CSS left or top directly.

How spring Works

spring() generates CSS cubic-bezier values from physical parameters.

// Conceptual preset definitions
const presets = {
  smooth: { tension: 120, friction: 14 },
  snappy: { tension: 300, friction: 20 },
  bouncy: { tension: 400, friction: 10 },
}

function spring(preset: keyof typeof presets) {
  const { tension, friction } = presets[preset]

  // Approximate bezier curve from physical parameters
  const bezier = approximateSpringCurve(tension, friction)
  const duration = calculateSettlingTime(tension, friction)

  return {
    duration,
    easing: `cubic-bezier(${bezier.join(', ')})`,
  }
}

// spring.transition() converts the above to a CSS transition string
spring.transition = (properties, preset) => {
  const { duration, easing } = spring(preset)
  const props = Array.isArray(properties) ? properties : [properties]
  return props.map(p => `${p} ${duration}ms ${easing}`).join(', ')
}

Although it is a bezier approximation rather than a true spring physical simulation, it is extremely lightweight because it runs natively in CSS.

How tween Works

tween is designed using JavaScript Generators.

function* tween(config: TweenConfig): Generator<number, number, number> {
  const { from, to, duration, curve } = config

  // Initialize on first next(), prepare to receive startTime
  let startTime: number | null = null

  while (true) {
    const currentTime: number = yield from  // Receive timestamp

    if (startTime === null) {
      startTime = currentTime
    }

    const elapsed = currentTime - startTime
    const progress = Math.min(elapsed / duration, 1)

    // Apply easing function
    const easedProgress = curve(progress)
    const value = from + (to - from) * easedProgress

    if (progress >= 1) {
      return to  // Done
    }
  }
}

By using Generators, it can encapsulate state while allowing timestamps to be injected from the outside via next(timestamp). Breaking the loop makes cancellation simple, and since values are generated only when needed, it is memory efficient.

By combining this with requestAnimationFrame, you can get accurate animation values for each frame.

The Deep Connection Between the key Prop and Animations

I found an interesting implementation detail while reading the source code.

The key prop isn't just for diffing optimization; it is directly linked to the trigger conditions for enter/exit animations.

// Without key: Same-position elements are treated as "updates"
{cond ? <span>🌙</span> : <span>☀️</span>}
// → Only the DOM is rewritten, no animation

// With key: Recognized as distinct elements
{cond ? <span key="moon">🌙</span> : <span key="sun">☀️</span>}
// → "moon" triggers exit animation, "sun" triggers enter animation

Internally, the keyed diff algorithm works as follows:

  1. Compare new/old children - O(1) matching using Map<key, index>
  2. Detect unmatched elements - Added to exitingNodes for deletion
  3. Start exit animation - Executed via Web Animations API
  4. Remove DOM after animation completion - Wait for animation.finished

What's even cleverer is the handling of cases where an element with the same key returns during an exit.

// Internal library implementation (from vdom.ts)
function reclaimExitingNode(exitingNode, newNode, ...) {
  let animation = exitingNode._animation
  if (animation && animation.playState === 'running') {
    animation.reverse()  // Reverse the animation!
  }
  // ...reuse the DOM
}

Consider clicking the theme toggle button repeatedly. If 🌙 starts to exit and fade out (opacity: 1 → 0), but then you switch the theme back immediately, 🌙 is needed again. In this case, findMatchingExitingNode() detects the 🌙 currently exiting, and animation.reverse() is called to play the animation backward (fade in), reusing the existing DOM.

It's interesting how this directly utilizes Web Animations API's reverse(). Instead of recreating a new DOM, it rewinds an ongoing animation to achieve a more natural transition.


Conclusion

The animation API in Remix v3.0.0-alpha.2 is designed for you to choose between the declarative animate, physics-based spring, and imperative tween depending on your needs.

Since all APIs are built upon web standards (Web Animations API, CSS Transitions, requestAnimationFrame), they are lightweight, performant, and allow you to leverage existing web knowledge directly. Being able to inspect them using browser DevTools is also a great developer experience.

In particular, the combination of animate's layout option and spring allows for rich UI with very little code, so please give it a try.

https://github.com/coji/remix-task-manager

GitHubで編集を提案

Discussion