caliper uidocs

Icon swap

Free

Turns one icon into another in place for a control whose meaning flips: copy to copied, play to pause, menu to close.

Installs components/ui/icon-swap.tsxDepends on none beyond ReactMotion shared motion tokens
pnpm dlx shadcn@latest add https://caliperui.site/r/icon-swap.json

When to use #

Use it for

  • Toggle buttons with an icon for each state.
  • A short confirmation inside an icon button.

Skip it when

  • Icons that change without a state change.
  • Text labels; use the action swap.

Installation #

terminal
pnpm dlx shadcn@latest add https://caliperui.site/r/icon-swap.json

Adds its 2 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.

Usage #

This is the code behind the preview above.

example.tsx
import { Check, Copy, Menu, Pause, Play, X } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { IconSwap } from "@/components/ui/icon-swap"

export function IconSwapDemo() {
  const [copied, setCopied] = useState(false);
  const [playing, setPlaying] = useState(false);
  const [open, setOpen] = useState(false);
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button variant="secondary" onClick={() => { setCopied(true); window.setTimeout(() => setCopied(false), 1600); }} aria-label={copied ? "Copied" : "Copy invite link"}>
        <IconSwap swapKey={copied ? "done" : "copy"} icon={copied ? <Check /> : <Copy />} />
        {copied ? "Copied" : "Copy link"}
      </Button>
      <Button size="icon" variant="secondary" aria-label={playing ? "Pause" : "Play"} aria-pressed={playing} onClick={() => setPlaying((p) => !p)}>
        <IconSwap swapKey={playing ? "pause" : "play"} icon={playing ? <Pause /> : <Play />} />
      </Button>
      <Button size="icon" variant="ghost" aria-label={open ? "Close menu" : "Open menu"} aria-expanded={open} onClick={() => setOpen((o) => !o)}>
        <IconSwap swapKey={open ? "close" : "menu"} icon={open ? <X /> : <Menu />} />
      </Button>
    </div>
  );
}

API reference #

IconSwap

component
PropTypeDefaultDescription
icon *ReactNode—
swapKey *string—Changes whenever the icon should change, e.g. "copy" and "copied".
classNamestring—

Accessibility #

It is built on native elements and keeps aria-hidden in step with state.

No custom key handling; Tab and the native activation keys do the work.

Motion #

Transitions use the shared duration and easing tokens. Under prefers-reduced-motion those tokens collapse to instant and travel distances go to zero, so state changes still happen without movement.