Chapter 6
Animation
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.
import { css } from "remix/ui";
import type { Handle, RemixNode } from "remix/ui";
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 Remix animation helpers when the motion depends on rendering state: a node enters, a node exits, a keyed item moves, or an event needs an interruptible animation.
import { createMixin, css, on } from "remix/ui";
import type { Handle } from "remix/ui";
import { spring } from "remix/ui/animation";
export function PressStateDemo(handle: Handle) {
let pressed = false;
function setPressed(nextPressed: boolean) {
if (pressed === nextPressed) return;
pressed = nextPressed;
handle.update();
}
return () => (
<div
tabIndex={0}
mix={[
pressableStyles,
onPressDown(() => {
setPressed(true);
}),
onPressUp(() => {
setPressed(false);
}),
]}
>
<span>{pressed ? "Pressed" : "Press me"}</span>
</div>
);
}
const onPressDown = createMixin<HTMLElement, [handler: () => void]>(() => (handler) => [
on("pointerdown", (event) => {
if (event.isPrimary === false) return;
handler();
}),
on("keydown", (event) => {
if (!(event.key === "Enter" || event.key === " ") || event.repeat) return;
event.preventDefault();
handler();
}),
]);
const onPressUp = createMixin<HTMLElement, [handler: () => void]>(() => (handler) => [
on("pointerup", handler),
on("pointerleave", handler),
on("keyup", (event) => {
if (!(event.key === "Enter" || event.key === " ")) return;
event.preventDefault();
handler();
}),
on("blur", handler),
]);
const pressableStyles = css({
display: "grid",
width: 120,
height: 120,
placeItems: "center",
borderRadius: 12,
backgroundColor: "#9911ff",
color: "white",
cursor: "pointer",
fontWeight: "800",
transition: `transform ${spring()}`,
"&:focus": {
outline: "4px solid rgba(0,120,255,0.7)",
outlineOffset: 2,
},
"&:hover, &:focus": {
transform: "scale(1.12)",
},
});
Entrance and exit animations
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/ui";
import { animateEntrance, animateExit, spring } from "remix/ui/animation";
import type { Handle } from "remix/ui";
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.
import { css, on } from "remix/ui";
import type { Handle } from "remix/ui";
import { animateEntrance, animateExit, spring } from "remix/ui/animation";
export function NoticePresenceDemo(handle: Handle) {
let visible = true;
return () => (
<div mix={stackStyles}>
<button
mix={[
buttonStyles,
on("click", () => {
visible = !visible;
handle.update();
}),
]}
type="button"
>
Toggle notice
</button>
{visible && (
<p
key="notice"
mix={[
noticeStyles,
animateEntrance({
opacity: 0,
transform: "translateY(8px)",
...spring("snappy"),
}),
animateExit({
opacity: 0,
transform: "translateY(-8px)",
...spring("snappy"),
}),
]}
>
Settings saved.
</p>
)}
</div>
);
}
const stackStyles = css({
display: "grid",
gap: "1rem",
minWidth: "16rem",
});
const buttonStyles = css({
border: "1px solid #d83a5a",
borderRadius: "999px",
background: "#d83a5a",
color: "white",
cursor: "pointer",
font: "inherit",
fontWeight: "700",
padding: "0.7rem 1rem",
});
const noticeStyles = css({
margin: 0,
borderRadius: "12px",
background: "#ecfdf5",
color: "#065f46",
padding: "0.75rem 1rem",
});
Layout animations
animateLayout(...) measures a host node before and after a render, then animates the visual delta. This is the right tool for sorted lists, expanding cards, and elements that move because layout changed.
import { clientEntry, css, on } from "remix/ui";
import { animateLayout, spring } from "remix/ui/animation";
import type { Handle } from "remix/ui";
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. Pass size: false when only position should animate and scaling the element's contents would look wrong.
import { css } from "remix/ui";
import type { Handle } from "remix/ui";
import { animateLayout, spring } from "remix/ui/animation";
export function ReorderingDemo(handle: Handle) {
let order = initialOrder;
function scheduleNextShuffle(signal: AbortSignal) {
let timeoutId = setTimeout(async () => {
if (signal.aborted) return;
order = shuffle(order);
let nextSignal = await handle.update();
if (nextSignal.aborted) return;
scheduleNextShuffle(nextSignal);
}, 1000);
signal.addEventListener("abort", () => clearTimeout(timeoutId), {
once: true,
});
}
handle.queueTask(scheduleNextShuffle);
return () => (
<ul mix={listStyles}>
{order.map((backgroundColor) => (
<li
key={backgroundColor}
mix={[
itemStyles,
animateLayout({
...spring({ duration: 600, bounce: 0.2 }),
}),
]}
style={{ backgroundColor }}
/>
))}
</ul>
);
}
const initialOrder = ["#ff0088", "#dd00ee", "#9911ff", "#0d63f8"];
function shuffle<item>(array: item[]): item[] {
let result = [...array];
for (let i = result.length - 1; i > 0; i--) {
let j = Math.floor(Math.random() * (i + 1));
let current = result[i];
result[i] = result[j];
result[j] = current;
}
return result;
}
const listStyles = css({
display: "flex",
position: "relative",
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "center",
width: 220,
gap: 10,
margin: 0,
padding: 0,
listStyle: "none",
});
const itemStyles = css({
width: 100,
height: 100,
borderRadius: 10,
});
Springs, tweens, and easing
spring() returns an iterator decorated for CSS transitions and Web Animations. Stringify it in CSS, spread it into animation options, or iterate it for custom JavaScript animation.
import { clientEntry, css, on } from "remix/ui";
import { spring } from "remix/ui/animation";
import type { Handle } from "remix/ui";
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/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.
import { css, on, ref } from "remix/ui";
import type { Handle } from "remix/ui";
import { spring } from "remix/ui/animation";
import type { SpringPreset } from "remix/ui/animation";
import { dragVelocityEvents } from "./gallery/drag-release.browser.ts";
const stageWidth = 420;
const stageHeight = 260;
const dotSize = 56;
const dotRadius = dotSize / 2;
export function SpringDragReleaseDemo(handle: Handle) {
let stage: HTMLDivElement;
let targetX = 310;
let targetY = 130;
let dotX = 110;
let dotY = 130;
let selectedPreset: SpringPreset = "bouncy";
let dragging = false;
let animating = false;
let ignoreNextStageClick = false;
let transitionX = String(spring(selectedPreset));
let transitionY = String(spring(selectedPreset));
function setTarget(event: PointerEvent | MouseEvent) {
if (ignoreNextStageClick) {
ignoreNextStageClick = false;
return;
}
let rect = stage.getBoundingClientRect();
targetX = clamp(event.clientX - rect.left, dotRadius, rect.width - dotRadius);
targetY = clamp(event.clientY - rect.top, dotRadius, rect.height - dotRadius);
handle.update();
}
function setDotPosition(event: PointerEvent) {
let rect = stage.getBoundingClientRect();
dotX = clamp(event.clientX - rect.left, dotRadius, rect.width - dotRadius);
dotY = clamp(event.clientY - rect.top, dotRadius, rect.height - dotRadius);
}
return () => (
<div mix={layoutStyle}>
<div
mix={[
stageStyle,
ref((node: HTMLDivElement) => {
stage = node;
}),
on<HTMLDivElement, "click">("click", setTarget),
]}
>
<div
aria-hidden="true"
mix={targetStyle}
style={{
left: `${targetX}px`,
top: `${targetY}px`,
transition: `left ${spring(selectedPreset)}, top ${spring(selectedPreset)}`,
}}
/>
<div
aria-label="Draggable spring dot"
role="button"
tabIndex={0}
mix={[
dotStyle,
dragVelocityEvents(),
on<HTMLDivElement, "pointerdown">("pointerdown", (event) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragging = true;
animating = false;
ignoreNextStageClick = true;
setDotPosition(event);
handle.update();
}),
on<HTMLDivElement, "pointermove">("pointermove", (event) => {
if (!dragging) return;
setDotPosition(event);
handle.update();
}),
on(dragVelocityEvents.release, (event) => {
dragging = false;
let distanceX = targetX - dotX;
let distanceY = targetY - dotY;
transitionX = readVelocitySpring(selectedPreset, event.velocityX, distanceX);
transitionY = readVelocitySpring(selectedPreset, event.velocityY, distanceY);
animating = true;
dotX = targetX;
dotY = targetY;
handle.update();
}),
on<HTMLDivElement, "transitionend">("transitionend", () => {
animating = false;
handle.update();
}),
]}
style={{
left: `${dotX}px`,
top: `${dotY}px`,
cursor: dragging ? "grabbing" : "grab",
transition: animating ? `left ${transitionX}, top ${transitionY}` : "none",
}}
/>
</div>
<div class="spring-demo-controls" mix={controlsStyle}>
{(Object.keys(spring.presets) as SpringPreset[]).map((preset) => (
<label key={preset} mix={controlLabelStyle}>
<input
type="radio"
name="spring-preset"
value={preset}
checked={selectedPreset === preset}
mix={on<HTMLInputElement, "change">("change", () => {
selectedPreset = preset;
transitionX = String(spring(selectedPreset));
transitionY = String(spring(selectedPreset));
handle.update();
})}
/>
{preset}
</label>
))}
</div>
<p mix={hintStyle}>Click the panel to move the target. Drag and release the dot.</p>
</div>
);
}
function readVelocitySpring(preset: SpringPreset, velocity: number, distance: number): string {
if (Math.abs(distance) < 1) {
return String(spring(preset));
}
let normalizedVelocity = clamp(velocity / distance, -20, 20);
return String(spring(preset, { velocity: normalizedVelocity }));
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
const layoutStyle = css({
display: "grid",
gap: "0.75rem",
width: "min(100%, 30rem)",
});
const stageStyle = css({
position: "relative",
overflow: "hidden",
width: "100%",
maxWidth: `${stageWidth}px`,
height: `${stageHeight}px`,
borderRadius: "18px",
background:
"radial-gradient(circle at 20% 20%, rgba(14, 165, 233, 0.2), transparent 35%), #17172a",
cursor: "crosshair",
touchAction: "none",
});
const targetStyle = css({
position: "absolute",
width: `${dotSize + 18}px`,
height: `${dotSize + 18}px`,
border: "2px dashed rgba(233, 69, 96, 0.65)",
borderRadius: "50%",
transform: "translate(-50%, -50%)",
pointerEvents: "none",
});
const dotStyle = css({
position: "absolute",
width: `${dotSize}px`,
height: `${dotSize}px`,
borderRadius: "50%",
backgroundColor: "#0ea5e9",
boxShadow: "0 0 20px rgba(14, 165, 233, 0.45)",
transform: "translate(-50%, -50%)",
userSelect: "none",
touchAction: "none",
"&:focus-visible": {
outline: "3px solid rgba(255, 255, 255, 0.8)",
outlineOffset: "3px",
},
});
const controlsStyle = css({
display: "flex",
flexWrap: "wrap",
gap: "0.35rem",
});
const controlLabelStyle = css({
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
border: "1px solid #d1d5db",
borderRadius: "999px",
padding: "0.4rem 0.65rem",
fontSize: "0.85rem",
});
const hintStyle = css({
margin: 0,
color: "#6b7280",
fontSize: "0.85rem",
textAlign: "center",
});
import { css, on } from "remix/ui";
import type { Handle } from "remix/ui";
import { spring } from "remix/ui/animation";
export function BouncySwitchDemo(handle: Handle) {
let isOn = true;
return () => (
<button
aria-pressed={isOn}
mix={[
switchStyles,
on("click", () => {
isOn = !isOn;
handle.update();
}),
]}
type="button"
>
<span
mix={thumbStyles}
style={{
transform: isOn ? "translateY(-100px)" : "translateY(0)",
transition: isOn ? `transform ${spring()}` : `transform 800ms ${bounceEasing}`,
}}
/>
</button>
);
}
const bounceEasing = `linear(0, 0.258 12%, 0.424 18.3%, 0.633 24.4%, 0.999 33.3%, 0.783 39.8%, 0.733 42.5%, 0.716 45.1%, 0.731 47.6%, 0.777 50.2%, 0.999 57.7%, 0.906 61.7%, 0.883 63.5%, 0.876 65.2%, 0.901 68.7%, 0.999 74.5%, 0.964 77.4%, 0.953 80.1%, 0.961 82.6%, 1 88.2%, 0.99 91.9%, 1)`;
const switchStyles = css({
display: "flex",
width: 80,
height: 180,
flexDirection: "column",
justifyContent: "flex-end",
border: 0,
borderRadius: 50,
backgroundColor: "#ff6b35",
cursor: "pointer",
padding: 10,
});
const thumbStyles = css({
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: "white",
willChange: "transform",
});
The UI package also keeps a gallery of smaller motion experiments. Keep this kind of broad demo in a bounded frame so it does not take over the surrounding guide page.
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
import { css, on, type Handle, type RemixNode } from "remix/ui";
import { DefaultAnimate } from "./gallery/default-animate.browser.tsx";
import { EnterAnimation } from "./gallery/enter.browser.tsx";
import { ExitAnimation } from "./gallery/exit.browser.tsx";
import { Press } from "./gallery/press.browser.tsx";
import { HTMLContent } from "./gallery/html-content.browser.tsx";
import { Keyframes } from "./gallery/keyframes.browser.tsx";
import { InterruptibleKeyframes } from "./gallery/interruptible-keyframes.browser.tsx";
import { RollingSquare } from "./gallery/rolling-square.browser.tsx";
import { Rotate } from "./gallery/rotate.browser.tsx";
import { TransitionOptions } from "./gallery/transition-options.browser.tsx";
import { Cube } from "./gallery/cube.browser.tsx";
import { SharedLayout } from "./gallery/shared-layout.browser.tsx";
import { AspectRatio } from "./gallery/aspect-ratio.browser.tsx";
import { BouncySwitch } from "./gallery/bouncy-switch.browser.tsx";
import { ColorInterpolation } from "./gallery/color-interpolation.browser.tsx";
import { FlipToggle } from "./gallery/flip-toggle.browser.tsx";
import { Reordering } from "./gallery/reordering.browser.tsx";
import { MultiStateBadge } from "./gallery/multi-state-badge.browser.tsx";
import { HoldToConfirm } from "./gallery/hold-to-confirm.browser.tsx";
import { MaterialRipple } from "./gallery/material-ripple.browser.tsx";
function Tile(handle: Handle<{ title: string; children: RemixNode; notes?: string }>) {
let remountKey = 0;
return () => {
let { title, children, notes } = handle.props;
return (
<div
mix={[
css({
backgroundColor: "white",
padding: "40px",
borderRadius: 12,
boxShadow: "0 0 10px 0 rgba(0, 0, 0, 0.1)",
display: "flex",
alignItems: "center",
flexDirection: "column",
gap: 12,
position: "relative",
}),
]}
>
<button
mix={[
css({
position: "absolute",
bottom: 8,
right: 8,
width: 18,
height: 18,
padding: 0,
border: "none",
background: "transparent",
cursor: "pointer",
opacity: 0.4,
"&:hover": {
opacity: 1,
},
}),
on("click", () => {
remountKey++;
handle.update();
}),
]}
title="Replay animation"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 4v6h6M23 20v-6h-6" />
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
</svg>
</button>
<h3 mix={[css({ margin: 0 })]}>{title}</h3>
<div
key={remountKey}
mix={[
css({
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 280,
}),
]}
>
{children}
</div>
{notes && (
<p
mix={[
css({
margin: 0,
fontSize: 12,
color: "#666",
textAlign: "center",
maxWidth: "200px",
}),
]}
>
{notes}
</p>
)}
</div>
);
};
}
export function AnimationGallery() {
return () => (
<>
<h1 mix={[css({ marginBottom: 0, "& + p": { marginTop: 0 } })]}>Animations</h1>
<p>
Most animations are adapted from <a href="https://www.motion.dev">Motion</a>. Thank you for
your work <a href="https://motion.dev/@matt">Matt Perry</a>!
</p>
<div
mix={[
css({
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))",
gap: 24,
marginTop: 40,
}),
]}
>
<Tile title="Default Animate" notes="animateEntrance + animateLayout defaults">
<DefaultAnimate />
</Tile>
<Tile title="Rolling Square" notes="CSS transition with spring() timing function">
<RollingSquare />
</Tile>
<Tile title="Enter Animation" notes="animateEntrance() with spring physics">
<EnterAnimation />
</Tile>
<Tile title="Exit Animation" notes="animateEntrance() + animateExit()">
<ExitAnimation />
</Tile>
<Tile title="Press Interaction" notes="CSS transition + pointer/keyboard events">
<Press />
</Tile>
<Tile title="HTML Content" notes="rAF loop with spring iterator for text">
<HTMLContent />
</Tile>
<Tile title="Keyframes" notes="CSS @keyframes with infinite loop">
<Keyframes />
</Tile>
<Tile title="Interruptible Keyframes" notes="Web Animations API with commitStyles()">
<InterruptibleKeyframes />
</Tile>
<Tile title="Rotate" notes="CSS @keyframes (one-shot)">
<Rotate />
</Tile>
<Tile title="Transition Options" notes="animateEntrance() with cubic-bezier + delay">
<TransitionOptions />
</Tile>
<Tile title="3D Cube" notes="rAF loop with direct style manipulation">
<Cube />
</Tile>
<Tile title="Shared Layout" notes="CSS Grid overlap for simultaneous enter/exit">
<SharedLayout />
</Tile>
<Tile title="Aspect Ratio">
<AspectRatio />
</Tile>
<Tile title="Bouncy Switch" notes="Spring up, bounce down with CSS linear()">
<BouncySwitch />
</Tile>
<Tile title="FLIP Toggle" notes="animateLayout() with interruptible WAAPI">
<FlipToggle />
</Tile>
<Tile title="Reordering" notes="animateLayout() with auto-shuffling list">
<Reordering />
</Tile>
<Tile title="Color Interpolation" notes="sRGB vs OKLCH color space">
<ColorInterpolation />
</Tile>
<Tile title="Multi-State Badge" notes="Animated icon/label swap with WAAPI shake">
<MultiStateBadge />
</Tile>
<Tile title="Hold to Confirm" notes="Custom interaction with progress tracking">
<HoldToConfirm />
</Tile>
<Tile title="Material Ripple" notes="Pointer-tracked ripples with enter/exit animations">
<MaterialRipple />
</Tile>
</div>
</>
);
}
Interruptible interactions
Event handlers receive abort signals, and animation mixins cancel or replace in-flight work when the DOM changes again. That makes interruption the default path instead of an edge case.
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/ui";
import { spring } from "remix/ui/animation";
import type { Handle } from "remix/ui";
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
Respect prefers-reduced-motion at the CSS boundary first. It works before JavaScript loads, applies to server-rendered frames, and covers static transitions.
import { css } from "remix/ui";
import type { Handle, RemixNode } from "remix/ui";
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;
}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.