iTranslated by AI
Exploring the New Animation API in Remix v3.0.0-alpha.2
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:

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.

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.

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',
}}
/>

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)',
}}
/>

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)
}

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:
-
Compare new/old children - O(1) matching using
Map<key, index> -
Detect unmatched elements - Added to
exitingNodesfor deletion - Start exit animation - Executed via Web Animations API
-
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.
Discussion