Back to components

Icon Swap

A controlled binary icon transition with stable layout, interruptible state changes, and reduced-motion support.

motion-primitivecrossfadeCSS-only alternative
"use client";

import * as React from "react";
import { motion, type Transition, useReducedMotion } from "motion/react";

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

/**
 * Both layers cross-fade in place — the outgoing icon shrinks and blurs away
 * while the incoming one sharpens into focus. The blur masks the frames where
 * both icons are semi-transparent, so the swap reads as one icon melting into
 * the other instead of two overlapping ghosts.
 */
const defaultTransition: Transition = {
  duration: 0.16,
  ease: [0.215, 0.61, 0.355, 1],
};

export type IconSwapProps = Omit<
  React.ComponentProps<"span">,
  "aria-hidden" | "children"
> & {
  /** Whether the active icon is visible. */
  active: boolean;
  /** Icon shown while inactive. */
  icon: React.ReactNode;
  /** Icon shown while active. */
  activeIcon: React.ReactNode;
  /** Whether state changes animate. */
  animated?: boolean;
  /** Classes applied to both icon layers. */
  iconClassName?: string;
  /** Transition shared by both icon layers. */
  transition?: Transition;
};

export function IconSwap({
  active,
  icon,
  activeIcon,
  animated = true,
  className,
  iconClassName,
  transition = defaultTransition,
  ref,
  ...props
}: IconSwapProps) {
  const reduceMotion = useReducedMotion();
  const shouldAnimate = animated && !reduceMotion;
  const resolvedTransition = shouldAnimate ? transition : { duration: 0 };
  const iconClasses = cn(
    "col-start-1 row-start-1 inline-flex origin-center",
    iconClassName,
  );

  return (
    <span
      {...props}
      ref={ref}
      data-slot="icon-swap"
      data-state={active ? "active" : "inactive"}
      data-animated={animated}
      aria-hidden="true"
      className={cn(
        "relative inline-grid shrink-0 place-items-center",
        className,
      )}
    >
      <motion.span
        initial={false}
        animate={{
          opacity: active ? 0 : 1,
          scale: active ? 0.5 : 1,
          filter: active ? "blur(2px)" : "blur(0px)",
        }}
        transition={resolvedTransition}
        data-slot="icon-swap-icon"
        data-state={active ? "closed" : "open"}
        className={iconClasses}
      >
        {icon}
      </motion.span>
      <motion.span
        initial={false}
        animate={{
          opacity: active ? 1 : 0,
          scale: active ? 1 : 0.5,
          filter: active ? "blur(0px)" : "blur(2px)",
        }}
        transition={resolvedTransition}
        data-slot="icon-swap-active-icon"
        data-state={active ? "open" : "closed"}
        className={iconClasses}
      >
        {activeIcon}
      </motion.span>
    </span>
  );
}

Installation

The command installs the Motion version. The CSS-only source is available above as a manual copy-paste variant when you want to avoid the animation dependency.

This command installs the Motion version. For CSS-only, use Manual and select the CSS only code variant.