p441z/Components/Swap
Motion · components/motion/swap.tsx

Swap

Trades an icon or a label for another on a spring — turn, roll, or pop.

59 linesframer-motionGitHub ↗
Install with the shadcn CLI

First time with the kit? Read the install guide

Day

About

Swap — trades one small thing for another on a spring: an icon turns in while the old one turns out, a label rolls up, a dot pops. Change `id` and it plays; same `id`, nothing moves.

Both states share one grid cell while they cross, so nothing around it jumps. `roll` clips to its own box (the words slide through a slot); `turn` and `pop` don't, so a rotating icon never gets its corners cut. Reduced motion: a plain crossfade.

Usage

<Swap id={copied ? "done" : "idle"}>{copied ? <CheckIcon /> : <CopyIcon />}</Swap>
<Swap id={dark ? "moon" : "sun"} mode="turn">{dark ? <Moon /> : <Sun />}</Swap>
<Swap id={label} mode="roll">{label}</Swap>

Installs with it

Source

components/motion/swap.tsx · 59 lines
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";

import { cn } from "@/lib/utils";
import { SPRING } from "@/lib/motion";

/**
 * Swap — trades one small thing for another on a spring: an icon turns in while the old one turns
 * out, a label rolls up, a dot pops. Change `id` and it plays; same `id`, nothing moves.
 *
 * Both states share one grid cell while they cross, so nothing around it jumps. `roll` clips to its
 * own box (the words slide through a slot); `turn` and `pop` don't, so a rotating icon never gets
 * its corners cut. Reduced motion: a plain crossfade.
 *
 * USAGE
 *   <Swap id={copied ? "done" : "idle"}>{copied ? <CheckIcon /> : <CopyIcon />}</Swap>
 *   <Swap id={dark ? "moon" : "sun"} mode="turn">{dark ? <Moon /> : <Sun />}</Swap>
 *   <Swap id={label} mode="roll">{label}</Swap>
 */

type Mode = "turn" | "roll" | "pop";

const MOVES = {
  turn: { from: { rotate: -90, scale: 0.4, opacity: 0 }, to: { rotate: 0, scale: 1, opacity: 1 }, out: { rotate: 90, scale: 0.4, opacity: 0 } },
  roll: { from: { y: "110%", opacity: 0 }, to: { y: "0%", opacity: 1 }, out: { y: "-110%", opacity: 0 } },
  pop: { from: { scale: 0.3, opacity: 0 }, to: { scale: 1, opacity: 1 }, out: { scale: 0.3, opacity: 0 } },
} as const;

interface SwapProps {
  /** What is showing. A new value plays the swap. */
  id: string | number;
  mode?: Mode;
  children: React.ReactNode;
  className?: string;
}

export default function Swap({ id, mode = "turn", children, className }: SwapProps) {
  const reduce = useReducedMotion();
  const m = MOVES[mode];
  return (
    <span className={cn("relative inline-grid place-items-center", mode === "roll" && "overflow-hidden", className)}>
      <AnimatePresence initial={false}>
        <motion.span
          key={id}
          className="col-start-1 row-start-1 inline-grid place-items-center"
          initial={reduce ? { opacity: 0 } : m.from}
          animate={reduce ? { opacity: 1 } : m.to}
          exit={reduce ? { opacity: 0 } : m.out}
          transition={{ ...(mode === "roll" ? SPRING.slide : SPRING.pop), opacity: { duration: 0.16 } }}
        >
          {children}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}