Skip to content

Swaps views the way you're going, follows their height and keeps focus.

01Preview

Step 1 of 3

Name your workspace

app.acme.dev/northwind-labs

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion

03Usage

import { PresenceSwap } from "@/components/ui/presence-swap";

const steps = ["account", "team", "billing"] as const;

<PresenceSwap value={step} order={steps}>
  {step === "account" && <AccountStep />}
  {step === "team" && <TeamStep />}
  {step === "billing" && <BillingStep />}
</PresenceSwap>

// No order between the views: crossfade in place.
<PresenceSwap value={method} variant="fade">
  {method === "email" ? <EmailInvite /> : <LinkInvite />}
</PresenceSwap>

04Source

"use client";
import { AnimatePresence, motion, useIsPresent, useReducedMotion, type Transition, type Variants } from "motion/react";
import { useLayoutEffect, useRef, useState } from "react";
import { AnimateHeight } from "@/components/ui/animate-height";
import { cn } from "@/lib/cn";
import { ease } from "@/lib/motion";

type Key = string | number;
type Direction = 1 | -1;

export type PresenceSwapProps<K extends Key> = Omit<React.ComponentProps<"div">, "children" | "style"> & {
  /** Which view is showing. Changing it swaps the children for the new ones. */
  value: K;
  /** The view for the current value. */
  children: React.ReactNode;
  /**
   * slide: the new view arrives from the side it lives on (forward from the right),
   * for steps, tabs and drill-downs. fade: a crossfade in place, for views with no order.
   */
  variant?: "slide" | "fade";
  /** Every value in order, so it knows which way is forward. Numbers compare on their own. */
  order?: readonly K[];
  /** Force the direction of this swap instead of inferring it. */
  direction?: Direction;
  /** How far a sliding view travels, in px. */
  distance?: number;
  /** Follow the height of each view on a spring. Off, the box jumps to the new height. */
  animateHeight?: boolean;
  /** Swap without animating while true, e.g. when the change came from an arrow key. */
  instant?: boolean;
  style?: Omit<React.CSSProperties, "height">;
};

function directionOf<K extends Key>(from: K, to: K, order?: readonly K[]): Direction {
  if (order) {
    const a = order.indexOf(from);
    const b = order.indexOf(to);
    if (a >= 0 && b >= 0) return b >= a ? 1 : -1;
  }
  if (typeof from === "number" && typeof to === "number") return to >= from ? 1 : -1;
  return 1;
}

type Custom = { dir: Direction; distance: number };

// Forward arrives from the right and leaves to the left; back is the mirror.
// The leaving view travels half as far and drops its opacity up front (ease-out,
// not ease-in), so the two views overlap for as little time as possible and
// never read as a double exposure.
const variants: Record<"slide" | "fade", Variants> = {
  slide: {
    enter: ({ dir, distance }: Custom) => ({ opacity: 0, x: dir * distance, filter: "blur(2px)" }),
    center: { opacity: 1, x: 0, filter: "blur(0px)", transition: { duration: 0.32, ease: ease.out } },
    exit: ({ dir, distance }: Custom) => ({
      opacity: 0,
      x: dir * -distance * 0.5,
      filter: "blur(2px)",
      transition: { duration: 0.18, ease: ease.outQuart },
    }),
  },
  fade: {
    enter: { opacity: 0, filter: "blur(2px)" },
    center: { opacity: 1, filter: "blur(0px)", transition: { duration: 0.24, ease: ease.out } },
    exit: { opacity: 0, filter: "blur(2px)", transition: { duration: 0.14, ease: ease.outQuart } },
  },
};

// Reduced motion keeps a short crossfade. Resting styles match the full variants,
// so server and client agree whatever the setting.
const reduced: Variants = {
  enter: { opacity: 0, x: 0, filter: "blur(0px)" },
  center: { opacity: 1, x: 0, filter: "blur(0px)", transition: { duration: 0.15 } },
  exit: { opacity: 0, transition: { duration: 0.1 } },
};
// Keyboard-driven or bulk changes: the new view is simply there.
const none: Transition = { duration: 0 };
const immediate: Variants = {
  enter: { opacity: 0, x: 0, filter: "blur(0px)" },
  center: { opacity: 1, x: 0, filter: "blur(0px)", transition: none },
  exit: { opacity: 0, transition: none },
};

export function PresenceSwap<K extends Key>({
  value,
  children,
  variant = "slide",
  order,
  direction,
  distance = 24,
  animateHeight = true,
  instant = false,
  className,
  ...rest
}: PresenceSwapProps<K>) {
  const reduce = useReducedMotion();
  const rootRef = useRef<HTMLDivElement>(null);
  // Remember the last value so the next swap knows which way it's going.
  const [shown, setShown] = useState({ value, dir: 1 as Direction, swaps: 0 });
  if (shown.value !== value) {
    setShown({ value, dir: direction ?? directionOf(shown.value, value, order), swaps: shown.swaps + 1 });
  }

  // A view on its way out can't be clicked, tabbed into or read. If focus was
  // inside it, hand focus to the view coming in, so keyboard users never land on
  // <body>: its [data-autofocus] element if it has one, else the view itself.
  useLayoutEffect(() => {
    const root = rootRef.current;
    if (!root || shown.swaps === 0) return;
    const panes = Array.from(root.querySelectorAll<HTMLElement>(":scope > div > [data-swap-pane]"));
    const leaving = panes.filter((p) => p.dataset.swapPane === "leaving");
    const entering = panes.find((p) => p.dataset.swapPane === "shown");
    const hadFocus = leaving.some((p) => p.contains(document.activeElement));
    for (const p of leaving) p.inert = true;
    if (hadFocus && entering) {
      const target = entering.querySelector<HTMLElement>("[data-autofocus], [autofocus]") ?? entering;
      target.focus({ preventScroll: true });
    }
  }, [shown.swaps]);

  const custom: Custom = { dir: shown.dir, distance };

  return (
    <AnimateHeight
      ref={rootRef}
      instant={instant || !animateHeight}
      data-variant={variant}
      data-direction={shown.dir === 1 ? "forward" : "back"}
      className={cn("min-w-0", className)}
      // popLayout lifts the leaving view out of the flow, positioned against this box.
      contentClassName="relative"
      {...rest}
    >
      <AnimatePresence initial={false} mode="popLayout" custom={custom}>
        <Pane key={value} custom={custom} variants={instant ? immediate : reduce ? reduced : variants[variant]}>
          {children}
        </Pane>
      </AnimatePresence>
    </AnimateHeight>
  );
}

function Pane({
  custom,
  variants,
  children,
  ref,
}: {
  custom: Custom;
  variants: Variants;
  children: React.ReactNode;
  ref?: React.Ref<HTMLDivElement>;
}) {
  const present = useIsPresent();
  return (
    <motion.div
      ref={ref}
      data-swap-pane={present ? "shown" : "leaving"}
      aria-hidden={present ? undefined : true}
      tabIndex={-1}
      custom={custom}
      variants={variants}
      initial="enter"
      animate="center"
      exit="exit"
      className={cn("outline-none", !present && "pointer-events-none")}
    >
      {children}
    </motion.div>
  );
}

05Props

PropTypeDefaultDescription
value*K extends string | numberWhich view is showing. Changing it swaps the children for the new ones.
children*ReactNodeThe view for the current value. Render it from value, as a switch or a lookup.
variant"slide" | "fade""slide"Slide in from the side the view lives on, or crossfade in place.
orderreadonly K[]Every value in order, so forward and back are known. Numbers compare on their own; anything else goes forward.
direction1 | -1Force the direction of this swap, e.g. back for a browser back button.
distancenumber24How far an arriving view travels, in px. The leaving one travels half.
animateHeightbooleantrueFollow each view's height on a spring. Off, the box jumps to the new height.
instantbooleanfalseSwap without animating while true, for changes that came from an arrow key or shortcut.

06Notes

Behavior

  • The leaving view is lifted out of the flow the moment the value changes, so the new one takes its place at once and the box starts moving toward the new height in the same frame.
  • The leaving view is inert and hidden from assistive tech for the length of its exit: rapid clicks always land on the view that is actually there.
  • If focus was inside the leaving view (Enter in a field that advances a step), it moves to the new view's [data-autofocus] element, or the view itself, instead of dropping to the page. Focus outside the swap, on a footer button, is left alone.
  • Direction follows order, so Back slides the other way. The leaving view is told the new direction even mid-exit, so reversing quickly never sends it the wrong way. Nested swaps are independent.

Motion

  • slide: the new view arrives from 24px with a 2px blur that clears, 320ms on the expo ease-out; the old one leaves 12px the other way in 180ms, fading up front so the two barely overlap.
  • fade: a 240ms crossfade with the same 2px blur, 140ms out. Height follows on the no-bounce spring from AnimateHeight, 180–350ms by distance, clipped top to bottom only.
  • No animation on first render. Reduced motion keeps a 150ms crossfade and changes the height at once; instant swaps on the same frame.

Accessibility

  • Adds no roles: give the region its own heading and announce step changes where you show them (a polite live region on 'Step 2 of 3').
  • The leaving view gets inert and aria-hidden, so it is never read twice or tabbed into.
  • Focus is handed to the new view only when it would otherwise be lost, never taken from a control outside the swap.