Swaps icons in a fixed box with a blur, turn or roll that knows direction.
Motion primitivesmotion
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 motion03Usage
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
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | K extends string | — | The 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. |
| size | number | 16 | Width and height of the box in px. The layout around it never moves. |
| direction | 1 | -1 | — | Force the turn or roll direction instead of inferring it from the order of icons. |
| label | string | — | Makes 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.