Skip to main content

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 motion

Morph 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:

Vortex tokenMotion value
--vortex-motion-duration-200duration: 0.2
--vortex-motion-easeease: [0.25, 0.1, 0.25, 1]
--vortex-motion-ease-inease: [0.42, 0, 1, 1]
--vortex-motion-ease-outease: [0.19, 0.91, 0.38, 1]
--vortex-motion-ease-in-outease: [0.42, 0, 0.58, 1]

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>