Motion
Animate layouts built with Vortex components using Motion. Vortex components already animate their own states, such as button presses and menus opening, so use Motion for movement between components.
bun add motionMorph between states
Add layout to a Motion element to animate its size when its content changes. Swap the content inside AnimatePresence with mode="popLayout", and set layout="position" on it so text doesn't stretch while the container resizes. Layout animations scale the element, so style the morphing surface with Vortex tokens rather than wrapping a Vortex component:
"use client";
import { Spinner, Text } from "@vortexlabs/vortex";
import { AnimatePresence, motion } from "motion/react";
import React from "react";
type Status = "idle" | "publishing" | "published";
const labels: Record<Status, string> = {
idle: "Publish",
publishing: "Publishing",
published: "Published",
};
const PublishButton = () => {
const [status, setStatus] = React.useState<Status>("idle");
React.useEffect(() => {
if (status === "idle") return;
const next = status === "publishing" ? "published" : "idle";
const timer = setTimeout(() => setStatus(next), 1500);
return () => clearTimeout(timer);
}, [status]);
return (
<motion.button
layout
type="button"
onClick={() => status === "idle" && setStatus("publishing")}
transition={{ type: "spring", bounce: 0.25, duration: 0.5 }}
style={{
borderRadius: 9999,
border: 0,
padding: "10px 20px",
cursor: "pointer",
color: "var(--vortex-color-foreground-inverse)",
background: "var(--vortex-color-background-surface-inverse)",
}}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={status}
layout="position"
initial={{ opacity: 0, y: 12, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -12, filter: "blur(4px)" }}
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
{status === "publishing" && <Spinner size="xs" color="inherit" ariaLabel="Publishing" />}
<Text as="span" color="inherit" weight="medium">
{labels[status]}
</Text>
</motion.span>
</AnimatePresence>
</motion.button>
);
};
export default PublishButton;Expand into a panel
The same technique works for larger changes. This Feedback button expands into a panel with a Vortex TextArea. One surface resizes while its content blurs between states, which stays smoother than morphing two separate elements with layoutId:
"use client";
import { Button, Stack, TextArea } from "@vortexlabs/vortex";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import React from "react";
const content = {
initial: { opacity: 0, filter: "blur(4px)" },
animate: { opacity: 1, filter: "blur(0px)" },
exit: { opacity: 0, filter: "blur(4px)" },
};
const FeedbackPanel = () => {
const [open, setOpen] = React.useState(false);
return (
<MotionConfig transition={{ type: "spring", bounce: 0.1, duration: 0.45 }}>
<motion.div
layout
animate={{ borderRadius: open ? 16 : 24 }}
style={{
overflow: "hidden",
background: "var(--vortex-color-background-surface-default)",
boxShadow: "0 0 0 1px var(--vortex-color-border-default), var(--vortex-shadow-300)",
}}
>
<AnimatePresence mode="popLayout" initial={false}>
{open ? (
<motion.div key="panel" layout="position" {...content} style={{ width: 320 }}>
<Stack gap={3} padding={4}>
<TextArea.Root>
<TextArea.Label>Feedback</TextArea.Label>
<TextArea.Input name="feedback" placeholder="What can we improve?" />
</TextArea.Root>
<Stack direction="row" gap={2} justify="end">
<Button variant="tertiary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="primary" onClick={() => setOpen(false)}>
Send
</Button>
</Stack>
</Stack>
</motion.div>
) : (
<motion.div key="trigger" layout="position" {...content}>
<Button variant="tertiary" onClick={() => setOpen(true)}>
Feedback
</Button>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</MotionConfig>
);
};
export default FeedbackPanel;Match Vortex motion tokens
Vortex motion tokens are CSS variables, while Motion takes durations in seconds and easing as numbers. Copy the values from your generated theme.css. The default Vortex theme uses:
Reduced motion
Vortex components follow the system reduced motion setting. Add MotionConfig around your app so Motion does too; it skips movement and layout animations and keeps fades:
import { MotionConfig } from "motion/react";
<MotionConfig reducedMotion="user">
{children}
</MotionConfig>