Skip to content

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/ui

CSS-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.

app/ui/pressable-card.tsx
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.

Press me

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.

app/ui/public/notice.tsx
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.

app/ui/public/reorder-list.tsx
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.

app/ui/public/bouncy-switch.tsx
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.

app/ui/public/count-up.ts
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

Item 1
Item 2

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
OKLCH

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:

app/ui/public/ripple-button.tsx
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.

app/ui/motion-safe-panel.tsx
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:

app/ui/public/motion.ts
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.