remix/ui/animation
animation provides small primitives for entrance, exit, layout, spring, and tween animation. Use these helpers with Remix UI mixins, CSS transitions, the Web Animations API, and imperative requestAnimationFrame loops.
Usage
import { animateEntrance, animateExit, animateLayout, spring } from 'remix/ui/animation'
let panelTransition = spring.transition(['opacity', 'transform'], 'snappy')
function Panel() {
return () => (
<div
style={{ transition: panelTransition }}
mix={[
animateEntrance({ opacity: 0, duration: 120 }),
animateExit({ opacity: 0, duration: 120 }),
animateLayout(),
]}
>
Saved filters
</div>
)
}Entrance And Exit
animateEntrance animates an element from the provided keyframe into its natural styles when the element is inserted.
import { animateEntrance, spring } from 'remix/ui/animation'
function Toast() {
return () => (
<div
mix={[
animateEntrance({
opacity: 0,
transform: 'translateY(8px)',
...spring('snappy'),
}),
]}
>
Saved
</div>
)
}animateExit keeps a removed keyed element in the DOM long enough to animate from its natural styles to the provided keyframe.
import type { Handle } from 'remix/ui'
import { animateExit } from 'remix/ui/animation'
function Item(handle: Handle<{ id: string; label: string }>) {
return () => (
<li
key={handle.props.id}
mix={[
animateExit({
opacity: 0,
transform: 'scale(0.96)',
duration: 120,
easing: 'ease-in',
}),
]}
>
{handle.props.label}
</li>
)
}Passing true uses the default opacity animation. Passing false, null, or undefined disables the animation.
<div mix={[animateEntrance(true), animateExit(false)]} />Animation configs combine WAAPI timing options with style properties for the animated keyframe.
type AnimateMixinConfig = {
duration: number
easing?: string
delay?: number
composite?: CompositeOperation
initial?: boolean
[property: string]: unknown
}Pass initial: false to skip only the first keyed entrance for an element within a parent. Later insertions for the same key can still animate.
<div key={id} mix={[animateEntrance({ opacity: 0, duration: 150, initial: false })]} />Exit animations can reclaim a removed keyed node if the same keyed element is rendered again before the exit finishes. The reclaimed node retargets toward its natural styles instead of simply reversing the exit animation.
Layout Animation
animateLayout animates layout changes with a FLIP-style transform projection. Use it on elements whose position or size can change between renders.
import type { Handle } from 'remix/ui'
import { animateLayout, spring } from 'remix/ui/animation'
function Card(handle: Handle<{ expanded: boolean }>) {
return () => (
<section
class={handle.props.expanded ? 'card expanded' : 'card'}
mix={[
animateLayout({
...spring('smooth'),
}),
]}
>
Details
</section>
)
}Pass size: false when the element should animate position only and avoid scale projection.
<div mix={[animateLayout({ duration: 180, easing: 'ease-out', size: false })]} />Passing true or no argument enables the default layout animation. Passing false, null, or undefined disables it. Layout animation skips work when geometry does not change, keeps in-flight animations running when their target geometry has not changed, and continues from the current visual transform when a new layout change interrupts an active animation.
Spring
spring returns a decorated iterator. It can be iterated for JavaScript animation, spread into Web Animations API options, or stringified for CSS transition syntax.
import { spring } from 'remix/ui/animation'
spring('bouncy')
spring('snappy')
spring('smooth')
spring({ duration: 400, bounce: 0.3 })interface SpringIterator extends IterableIterator<number> {
duration: number
easing: string
toString(): string
}Use spring.transition to build CSS transition entries.
let transition = spring.transition(['opacity', 'transform'], 'bouncy')
function Button() {
return () => <button style={{ transition }}>Save</button>
}Spread a spring into animation mixin or WAAPI options.
<div
mix={[
animateEntrance({
opacity: 0,
transform: 'scale(0.92)',
...spring('bouncy'),
}),
]}
/>element.animate(
[
{ opacity: 0, transform: 'scale(0.92)' },
{ opacity: 1, transform: 'scale(1)' },
],
{ ...spring('snappy') },
)Use the iterator values as progress from 0 to 1 for imperative animation.
let from = 0
let to = 200
for (let progress of spring('bouncy')) {
let x = from + (to - from) * progress
element.style.transform = `translateX(${x}px)`
await nextFrame()
}The built-in presets are:
| Preset | Bounce | Duration | Character |
|---|---|---|---|
smooth |
-0.3 | 400ms | Overdamped, no overshoot |
snappy |
0 | 200ms | Quick, no overshoot |
bouncy |
0.3 | 400ms | Underdamped bounce |
Override preset duration or velocity with the second argument.
spring('bouncy', { duration: 300 })
spring('snappy', { velocity: 2 })Use explicit options when you need full control.
spring({
duration: 500,
bounce: 0.35,
velocity: 0,
})Tween
tween creates a generator that interpolates a numeric value over time with a cubic-bezier curve. Call next() once to initialize the generator, then pass requestAnimationFrame timestamps into next(timestamp).
import { easings, tween } from 'remix/ui/animation'
let animation = tween({
from: 0,
to: 100,
duration: 300,
curve: easings.easeOut,
})
animation.next()
function tick(timestamp: number) {
let { value, done } = animation.next(timestamp)
element.style.transform = `translateX(${value}px)`
if (!done) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)Animate multiple values with separate tweens.
let xAnimation = tween({ from: 0, to: 100, duration: 500, curve: easings.easeOut })
let scaleAnimation = tween({ from: 1, to: 1.2, duration: 500, curve: easings.easeOut })
xAnimation.next()
scaleAnimation.next()
function tick(timestamp: number) {
let x = xAnimation.next(timestamp)
let scale = scaleAnimation.next(timestamp)
element.style.transform = `translateX(${x.value}px) scale(${scale.value})`
if (!x.done || !scale.done) {
requestAnimationFrame(tick)
}
}The built-in easing presets are cubic-bezier control points matching common CSS timing functions.
easings.linear
easings.ease
easings.easeIn
easings.easeOut
easings.easeInOutCustom curves use the same control points as CSS cubic-bezier(x1, y1, x2, y2).
let animation = tween({
from: 0,
to: 100,
duration: 500,
curve: { x1: 0.68, y1: -0.55, x2: 0.265, y2: 1.55 },
})API
animateEntrance(config?): mixin that animates an element when it enters the DOM.animateExit(config?): mixin that persists a removed keyed element long enough to run its exit animation.animateLayout(config?): mixin that animates layout changes by comparing geometry between renders.spring(preset?, overrides?): creates aSpringIteratorfrom a named preset.spring(options?): creates aSpringIteratorfrom explicit spring options.spring.transition(property, presetOrOptions?, overrides?): builds one or more CSS transition entries from a spring.spring.presets: namedsmooth,snappy, andbouncyspring defaults.tween(options): generator that interpolates numeric values over time with a cubic-bezier curve.easings: common cubic-bezier presets fortween.SpringIterator,SpringPreset,SpringOptions,TweenOptions, andBezierCurve: public TypeScript types for spring and tween configuration.
Behavior Notes
- Animation mixin style properties are copied into WAAPI keyframes;
duration,easing,delay,composite, andinitialare treated as options. animateEntrance({ initial: false })only skips the first keyed entrance tracked for the parent node.animateExitneeds keyed elements when removed nodes may be reclaimed or persisted across list updates.animateLayout({ size: false })animates translation without scale projection.spring()yields progress values from0to1; itsdurationandeasingproperties are enumerable so{ ...spring() }works with WAAPI options.tween(...)yields the initial value first; advance the generator with frame timestamps vianext(timestamp)and readdoneto detect completion.