Chapter 7
Animation
Motion in a Remix app starts in CSS. Styles from css(...) in
remix/component handle hover, focus, and other browser-owned
states. When motion depends on what a component renders, such as a node entering, leaving, or moving
in a keyed list, the mixins and timing helpers in
@remix-run/ui/animation take over.
@remix-run/ui is currently unstable and versioned independently. It
is not available through the remix package, so install it separately:
npm i remix @remix-run/uiCSS-first visual states
Start with CSS when the browser already owns the state. Hover, focus, active, checked, open, selected, and reduced-motion states do not need JavaScript state unless your render output also depends on them.
import { css } from "remix/component";
import type { Handle, RemixNode } from "remix/component";
export function PressableCard(handle: Handle<{ children: RemixNode }>) {
return () => (
<button mix={cardStyle} type="button">
{handle.props.children}
</button>
);
}
const cardStyle = css({
border: "1px solid #d6d6d6",
borderRadius: "16px",
background: "white",
cursor: "pointer",
font: "inherit",
padding: "1rem",
transition: "transform 160ms ease, box-shadow 160ms ease",
"&:hover, &:focus-visible": {
boxShadow: "0 12px 32px rgba(15, 17, 21, 0.14)",
transform: "translateY(-2px)",
},
"&:active": {
transform: "translateY(0)",
},
"@media (prefers-reduced-motion: reduce)": {
transition: "none",
},
});Use the animation helpers from @remix-run/ui/animation when the motion depends on rendering state, such as a node entering, a node exiting, a keyed item moving, or an event that needs an interruptible animation.
Entrance and exit animations
These mixins attach to host nodes, the DOM elements such as <p> or <li> that a component renders, not to components themselves. animateEntrance(...) runs when a host node is inserted. animateExit(...) lets a removed node stay in the DOM until its exit animation finishes.
import { clientEntry, css, on } from "remix/component";
import { animateEntrance, animateExit, spring } from "@remix-run/ui/animation";
import type { Handle } from "remix/component";
export const Notice = clientEntry(import.meta.url, function Notice(handle: Handle) {
let visible = true;
return () => (
<div>
<button
mix={[
on("click", () => {
visible = !visible;
handle.update();
}),
]}
type="button"
>
Toggle notice
</button>
{visible && (
<p
key="notice"
mix={[
noticeStyle,
animateEntrance({
opacity: 0,
transform: "translateY(8px)",
...spring("snappy"),
}),
animateExit({
opacity: 0,
transform: "translateY(-8px)",
...spring("snappy"),
}),
]}
>
Settings saved.
</p>
)}
</div>
);
});
const noticeStyle = css({
borderRadius: "12px",
background: "#ecfdf5",
color: "#065f46",
padding: "0.75rem 1rem",
});Keep keys stable when toggling between related elements. A stable key tells Remix which node is entering, exiting, or being replaced.
Pass true for the default opacity animation or false to disable a mixin without changing the surrounding mix array. animateEntrance({ initial: false }) skips the first insertion for a key but still animates later insertions. If the same keyed element returns before its exit finishes, Remix reclaims that DOM node and animates it back toward its rendered styles.
Settings saved.
Layout animations
animateLayout(...) measures a host node before and after a render, then animates the visual delta. Use it for sorted lists, expanding cards, and elements that move because layout changed.
import { clientEntry, css, on } from "remix/component";
import { animateLayout, spring } from "@remix-run/ui/animation";
import type { Handle } from "remix/component";
const initialItems = ["Design", "Build", "Review"];
export const ReorderList = clientEntry(import.meta.url, function ReorderList(handle: Handle) {
let items = initialItems;
return () => (
<div>
<button
mix={[
on("click", () => {
items = [...items].reverse();
handle.update();
}),
]}
type="button"
>
Reverse
</button>
<ul mix={listStyle}>
{items.map((item) => (
<li key={item} mix={[itemStyle, animateLayout({ ...spring("bouncy") })]}>
{item}
</li>
))}
</ul>
</div>
);
});
const listStyle = css({
display: "grid",
gap: "0.5rem",
listStyle: "none",
padding: 0,
});
const itemStyle = css({
border: "1px solid #d6d6d6",
borderRadius: "10px",
padding: "0.7rem 1rem",
});key is not optional for layout animation in lists. Without stable keys, the runtime cannot know whether an item moved or a different item replaced it.
Layout animation includes size projection by default, which animates a change in size by scaling the element with a transform. Pass size: false when only position should animate and scaling the element's contents would look wrong.
Springs, tweens, and easing
spring() returns an iterator decorated for CSS transitions and the Web Animations API (WAAPI), the browser API behind element.animate(). Stringify it in CSS, spread it into animation options, or iterate it for custom JavaScript animation.
import { clientEntry, css, on } from "remix/component";
import { spring } from "@remix-run/ui/animation";
import type { Handle } from "remix/component";
export const BouncySwitch = clientEntry(import.meta.url, function BouncySwitch(handle: Handle) {
let enabled = true;
return () => (
<button
aria-pressed={enabled}
mix={[
switchStyle,
on("click", () => {
enabled = !enabled;
handle.update();
}),
]}
type="button"
>
<span
mix={thumbStyle}
style={{
transform: enabled ? "translateX(2.25rem)" : "translateX(0)",
}}
/>
</button>
);
});
const switchStyle = css({
border: 0,
borderRadius: "999px",
background: "#ff6b35",
cursor: "pointer",
padding: "0.25rem",
width: "5rem",
});
const thumbStyle = css({
display: "block",
width: "2rem",
height: "2rem",
borderRadius: "50%",
background: "white",
transition: spring.transition("transform", "bouncy"),
});Use tween(...) when you need a time-based value loop rather than CSS or WAAPI timing.
import { easings, tween } from "@remix-run/ui/animation";
export function countUp(from: number, to: number, onValue: (value: number) => void) {
let animation = tween({ from, to, duration: 300, curve: easings.easeOut });
animation.next();
function tick(timestamp: number) {
let result = animation.next(timestamp);
onValue(result.value);
if (!result.done) {
requestAnimationFrame(tick);
}
}
requestAnimationFrame(tick);
}Prefer CSS transitions and animation mixins for ordinary UI. Use tween for canvas, custom counters, or values that are not CSS properties.
Click the panel to move the target. Drag and release the dot.
The gallery below collects smaller motion experiments built with the same helpers.
Animations
Most animations are adapted from Motion. Thank you for your work Matt Perry!
Default Animate
animateEntrance + animateLayout defaults
Rolling Square
CSS transition with spring() timing function
Enter Animation
animateEntrance() with spring physics
Exit Animation
animateEntrance() + animateExit()
Press Interaction
CSS transition + pointer/keyboard events
HTML Content
0
rAF loop with spring iterator for text
Keyframes
CSS @keyframes with infinite loop
Interruptible Keyframes
Web Animations API with commitStyles()
Rotate
CSS @keyframes (one-shot)
Transition Options
animateEntrance() with cubic-bezier + delay
3D Cube
rAF loop with direct style manipulation
Shared Layout
CSS Grid overlap for simultaneous enter/exit
Aspect Ratio
Bouncy Switch
Spring up, bounce down with CSS linear()
FLIP Toggle
animateLayout() with interruptible WAAPI
Reordering
animateLayout() with auto-shuffling list
Color Interpolation
sRGB vs OKLCH color space
Multi-State Badge
Animated icon/label swap with WAAPI shake
Hold to Confirm
Custom interaction with progress tracking
Material Ripple
Pointer-tracked ripples with enter/exit animations
Interruptible interactions
Event handlers receive an AbortSignal that aborts when the same event fires again or the element is removed, and animation mixins cancel or replace in-flight work when the DOM changes again. If a user toggles a notice twice quickly, the notice animates back toward its rendered styles from wherever its exit had reached, instead of finishing the exit first.
For custom imperative animations, keep the current animation in setup scope and cancel it before starting the next one:
import { clientEntry, css, on, ref } from "remix/component";
import { spring } from "@remix-run/ui/animation";
import type { Handle } from "remix/component";
export const RippleButton = clientEntry(import.meta.url, function RippleButton(_handle: Handle) {
let node: HTMLButtonElement;
let currentAnimation: Animation | undefined;
return () => (
<button
mix={[
ref((element) => (node = element)),
buttonStyle,
on("pointerdown", () => {
currentAnimation?.cancel();
currentAnimation = node.animate(
[{ transform: "scale(0.96)" }, { transform: "scale(1)" }],
{ ...spring("snappy") },
);
}),
]}
type="button"
>
Press me
</button>
);
});
const buttonStyle = css({
border: 0,
borderRadius: "999px",
background: "#9911ff",
color: "white",
cursor: "pointer",
font: "inherit",
fontWeight: "700",
padding: "0.8rem 1.1rem",
});CSS transitions are also interruptible. Changing the target value while a transition is running makes the browser animate from the current visual state to the new one.
Reduced-motion behavior
The prefers-reduced-motion media query reports whether the user has asked their operating system to minimize motion. Respect it at the CSS boundary first. It works before JavaScript loads, applies to server-rendered frames, and covers static transitions.
import { css } from "remix/component";
import type { Handle, RemixNode } from "remix/component";
export function MotionSafePanel(handle: Handle<{ children: RemixNode }>) {
return () => <section mix={panelStyle}>{handle.props.children}</section>;
}
const panelStyle = css({
opacity: 1,
transform: "translateY(0)",
transition: "opacity 180ms ease, transform 180ms ease",
"@media (prefers-reduced-motion: reduce)": {
transition: "none",
transform: "none",
},
});For JavaScript-driven motion, check the same media query before starting work:
export function prefersReducedMotion() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}@remix-run/ui/animation exports the same check as prefersReducedMotion(). It also returns false when matchMedia is unavailable, so it is safe to call during server rendering.
Reduced motion does not have to mean no feedback. Prefer shorter fades, instant layout changes, or non-motion state changes when movement is not essential.