Skip to content

Swaps icons in a fixed box with a blur, turn or roll that knows direction.

01Preview

q3-roadmap-review.m4a

Priya Raman · 12:48

Appearance

Follows your device

Files
  • q3-forecast.xlsx2h ago
  • brand-guidelines.pdfYesterday
  • onboarding-v2.fig3 days ago

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 { IconSwap } from "@/components/ui/icon-swap";
import { Pause, Play } from "@/components/ui/icons";

<button aria-label={playing ? "Pause" : "Play"} onClick={toggle}>
  <IconSwap value={playing ? "pause" : "play"} icons={{ play: <Play />, pause: <Pause /> }} />
</button>

// A cycle turns the same way round, even when it wraps.
<IconSwap value={theme} icons={{ system: <Monitor />, light: <Sun />, dark: <Moon /> }} variant="rotate" />

04Source

"use client";
import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion/react";
import { useState } from "react";
import { cn } from "@/lib/cn";
import { spring, swap } from "@/lib/motion";

type Direction = 1 | -1;

export type IconSwapProps<K extends string> = Omit<React.ComponentProps<"span">, "children"> & {
  /** Which icon is showing. Changing it swaps. */
  value: K;
  /** Every icon it can show, by key. Their order sets which way "forward" turns or rolls. */
  icons: Record<K, React.ReactNode>;
  /**
   * blur: scale and blur through each other, for state changes (play, pause, copied).
   * rotate: turn a quarter as they swap, for toggles and cycles (menu, theme).
   * slide: roll through the box like a counter, for ordered values (sort direction).
   */
  variant?: "blur" | "rotate" | "slide";
  /** The box, in px. Nothing around it moves while icons swap, whatever their size. */
  size?: number;
  /** Force the turn or roll direction. Otherwise it follows the order of `icons`. */
  direction?: Direction;
  /** Give the icon a name (role="img") when it isn't inside a labelled control. */
  label?: string;
};

// Which way is forward: the shorter way round the list, so a three-state cycle
// that wraps from last to first keeps turning the same way.
function directionOf(keys: string[], from: string, to: string): Direction {
  const a = keys.indexOf(from);
  const b = keys.indexOf(to);
  if (a < 0 || b < 0) return 1;
  if (keys.length < 3) return b > a ? 1 : -1;
  const ahead = (b - a + keys.length) % keys.length;
  return ahead <= keys.length / 2 ? 1 : -1;
}

const variants: Record<"blur" | "rotate" | "slide", Variants> = {
  blur: { enter: swap.initial, center: swap.animate, exit: swap.exit },
  rotate: {
    enter: (d: Direction) => ({ opacity: 0, scale: 0.6, rotate: -90 * d, filter: "blur(2px)" }),
    center: { opacity: 1, scale: 1, rotate: 0, filter: "blur(0px)" },
    exit: (d: Direction) => ({ opacity: 0, scale: 0.6, rotate: 90 * d, filter: "blur(2px)" }),
  },
  slide: {
    enter: (d: Direction) => ({ opacity: 0, y: `${75 * d}%`, filter: "blur(1.5px)" }),
    center: { opacity: 1, y: "0%", filter: "blur(0px)" },
    exit: (d: Direction) => ({ opacity: 0, y: `${-75 * d}%`, filter: "blur(1.5px)" }),
  },
};

// Reduced motion keeps only the crossfade. The resting state stays identical to the
// full variant, so server and client render the same styles whatever the setting.
const faded = (v: Variants): Variants => ({ enter: { ...v.center, opacity: 0 }, center: v.center, exit: { ...v.center, opacity: 0 } });
const reduced = { blur: faded(variants.blur), rotate: faded(variants.rotate), slide: faded(variants.slide) };

export function IconSwap<K extends string>({
  value,
  icons,
  variant = "blur",
  size = 16,
  direction,
  label,
  className,
  style,
  ...rest
}: IconSwapProps<K>) {
  const reduce = useReducedMotion();
  // Remember the last value so the next swap knows which way it's going.
  const [shown, setShown] = useState({ value, dir: 1 as Direction });
  if (shown.value !== value) {
    setShown({ value, dir: direction ?? directionOf(Object.keys(icons), shown.value, value) });
  }

  return (
    <span
      data-variant={variant}
      role={label ? "img" : undefined}
      aria-label={label}
      aria-hidden={label ? undefined : true}
      className={cn(
        "relative inline-grid shrink-0 place-items-center align-middle",
        // A roll reads as a counter only if it's clipped to the box.
        variant === "slide" && "overflow-hidden",
        className,
      )}
      style={{ width: size, height: size, ...style }}
      {...rest}
    >
      <AnimatePresence initial={false} custom={shown.dir}>
        <motion.span
          key={value}
          custom={shown.dir}
          variants={(reduce ? reduced : variants)[variant]}
          initial="enter"
          animate="center"
          exit="exit"
          transition={reduce ? { duration: 0.12 } : variant === "slide" ? spring.snappy : spring.pop}
          className="pointer-events-none absolute inset-0 grid place-items-center [&>svg]:shrink-0"
        >
          {icons[value]}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

05Props

PropTypeDefaultDescription
value*K extends stringThe key of the icon to show. Changing it swaps.
icons*Record<K, ReactNode>Every icon it can show, by key. Their order decides which way is forward.
variant"blur" | "rotate" | "slide""blur"Scale and blur through each other, turn a quarter, or roll through the box like a counter.
sizenumber16Width and height of the box in px. The layout around it never moves.
direction1 | -1Force the turn or roll direction instead of inferring it from the order of icons.
labelstringMakes it role="img" with this name. Leave it out inside a labelled button; the icon is then aria-hidden.

06Notes

Behavior

  • Both icons are absolutely placed in a box of fixed size, so a wider or taller glyph can never nudge a label or resize its button.
  • Direction comes from the order of icons: forward turns clockwise and rolls up. With three or more it takes the shorter way round, so a theme cycle that wraps from last to first keeps turning the same way.
  • Pressing again mid-swap retargets from where the icons are; the outgoing icon keeps the new direction because it is handed through AnimatePresence, not captured at mount.
  • The outgoing icon ignores the pointer, so rapid presses always land on the button, never on a ghost.

Motion

  • blur: scale 0.6 → 1 with a 3px blur that clears, on the pop spring (600 / 30 / 0.6), about 150ms to settle. The same values as every swap in the library.
  • rotate: a quarter turn with scale 0.6 and a 2px blur on the pop spring. slide: 75% of the box with a 1.5px blur on the snappy spring, clipped so it reads as a roll.
  • No animation on first render. Reduced motion keeps a 120ms crossfade and drops scale, rotation, travel and blur; the resting styles are identical either way, so nothing mismatches on hydration.

Accessibility

  • Decorative by default (aria-hidden): the button around it carries the name, e.g. Play and Pause, or Mute with aria-pressed.
  • Pass label when the icon stands alone and conveys state; it becomes role="img" with that name.