Animated Icon

Animated

Two icons in one place, trading with a transition instead of a swap.

View Source

#Installation

npx @dinachi/cli@latest add animated-icon

#Usage

tsx
import { AnimatedIcon } from "@/components/ui/animated-icon"
import { Play, Pause } from "lucide-react"
tsx
<button type="button" aria-label={playing ? "Pause" : "Play"} onClick={toggle}>
  <AnimatedIcon active={playing} from={<Play />} to={<Pause />} />
</button>

#Examples

#What the motion is for

The two states read as the same object changing, so the control is one toggle rather than two buttons taking turns. Swap the element outright and the eye registers a replacement; move it and the eye registers a state.

#It ships no icons

Give it any two icons and it handles the crossfade, the stacking, the sizing and the reduced-motion path. A lucide pair, your own SVG, an emoji, two spans.

tsx
<AnimatedIcon active={muted} from={<Volume2 />} to={<VolumeX />} />
<AnimatedIcon active={copied} from={<Copy />} to={<Check />} />
<AnimatedIcon active={open} from={<Menu />} to={<X />} mode="rotate" />

It does not morph one path into another. That needs two paths written with the same commands in the same order, and no two icons out of a library ever are. A morph between mismatched paths does not degrade, it fails. The transform pair works for every combination instead of for the handful that happen to be compatible.

#Modes

ModeWhat happens
scaleThe outgoing icon drops to 0.7 and fades; the incoming one rises from it. The default.
rotateAdds a quarter turn each way, so the icon reads as turning over.
flipRotates about the horizontal axis, with a vanishing point.
fadeOpacity only. For pairs where any movement would be noise.

exit mirrors initial rather than repeating it, so the icon that leaves goes the way the next one is coming from and the pair reads as one movement rather than as two.

#Timing

150ms, no bounce, no delay. These sit on controls someone presses dozens of times a day, and the motion must never come between the press and the answer. Anything springier here would be felt as lag rather than as character.

#Sizing

The box owns the size and both icons fill it. Default size-5.

tsx
<AnimatedIcon active={dark} from={<Sun />} to={<Moon />} className="size-8" />

Do not size the icons individually. An icon library ships an intrinsic 24×24, which a smaller box squashes on one axis and a larger one leaves floating in the middle. Two icons sized apart are two icons that jump as they trade.

#When to use it

Use it forNot for
A control with two states the reader toggles: a play button, a mute button, a disclosure.Icons that never change. There is no second state to travel to.
Confirmation, once, at the end of an action: copy becoming a check.Decoration. An icon that animates for its own sake on a page someone visits daily is noise.

#Implementation notes

  • Nothing appears from nothing. scale starts at 0.7, not 0.
  • initial={false} on the AnimatePresence, so the icon is simply present on first paint rather than animating in on every mount during hydration.
  • perspective only under flip. A rotation about an axis in the plane of the screen is invisible without a vanishing point; the other three do not need one and should not pay for one.
  • One grid cell, not absolute positioning. Both icons occupy the same cell, so nothing reflows mid-transition and neither one has to be measured.

#Accessibility

  • It carries aria-hidden. It is decoration inside a control, and the control is what gets named.
  • Name the button, not the icon. If the button's label does not change with its state, put the state in aria-expanded or aria-pressed.
  • Reduced motion cuts to the end state. Which icon is showing is the information; watching it arrive is not.

#API Reference

PropTypeDefaultDescription
activebooleanWhich of the two is showing. false shows from, true shows to
fromReactNodeThe resting icon. Any element: a lucide icon, an inline svg, an emoji
toReactNodeThe icon for the active state
mode"scale" | "rotate" | "flip" | "fade""scale"How the two trade places
classNamestring"size-5"Sizes and colours both icons. The shell owns the size; do not size the icons individually