Skip to content

Icon swap

A cross-fade between two icons in one cell, so a state change never shifts layout.

Status
Stable
Level
Atom
Category
Content
Adoption
Not used yet
import { IconSwap } from "@oration/canon/components/icon-swap";
packages/canon/src/components/icon-swap.tsx

Northwind Freight

INV-20931

import { Button } from "@oration/canon/components/button";import { IconSwap } from "@oration/canon/components/icon-swap";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { cn } from "@oration/canon/lib/utils";import { CheckIcon, CopyIcon, StarIcon } from "lucide-react";import * as React from "react";export function Hero() {    const [favorite, setFavorite] = React.useState(false);    const [copied, setCopied] = React.useState(false);    React.useEffect(() => {        if (!copied) return;        const id = window.setTimeout(() => setCopied(false), 1500);        return () => window.clearTimeout(id);    }, [copied]);    return (        <div className="flex w-full max-w-md items-center gap-3 rounded-xl bg-card p-4 shadow-border">            <div className="flex min-w-0 flex-1 flex-col">                <p className="text-sm font-medium text-foreground">                    Northwind Freight                </p>                <p className="font-mono text-xs text-muted-foreground">                    INV-20931                </p>            </div>            <Tooltip>                <TooltipTrigger                    render={                        <Button                            type="button"                            variant="ghost"                            size="icon-sm"                            aria-label={                                copied ? "Copied" : "Copy invoice number"                            }                            onClick={() => {                                void navigator.clipboard?.writeText(                                    "INV-20931",                                );                                setCopied(true);                            }}                        />                    }                >                    <IconSwap swapKey={copied}>                        {copied ? (                            <CheckIcon aria-hidden="true" />                        ) : (                            <CopyIcon aria-hidden="true" />                        )}                    </IconSwap>                </TooltipTrigger>                <TooltipContent>                    {copied ? "Copied" : "Copy invoice number"}                </TooltipContent>            </Tooltip>            <Tooltip>                <TooltipTrigger                    render={                        <Button                            type="button"                            variant="ghost"                            size="icon-sm"                            aria-label="Favorite"                            aria-pressed={favorite}                            onClick={() => setFavorite((value) => !value)}                        />                    }                >                    <IconSwap swapKey={favorite}>                        <StarIcon                            aria-hidden="true"                            className={cn(                                favorite                                    ? "fill-warning text-warning"                                    : "text-muted-foreground",                            )}                        />                    </IconSwap>                </TooltipTrigger>                <TooltipContent>                    {favorite ? "Remove favorite" : "Favorite"}                </TooltipContent>            </Tooltip>        </div>    );}

Usage#

Icon swap cross-fades between the icons of one control as its state changes: copy to check, play to pause, an empty star to a filled one, a spinner to a check. Both icons share one grid cell, so the swap never shifts the button or the text beside it. It animates only when swapKey changes, so the mistake is changing the icon without changing the key, which snaps it. It is purely visual: the control around it still has to say its state in words.

When to use

  • For a confirmation that replaces the action's icon for a moment: Copy to a check after copying an invoice number.
  • For a toggle whose icon shows its state: favorite, play and pause, show and hide a password.
  • For a short task that ends in a result in the same spot: a spinner while a domain is checked, then a check.
  • For a checklist item that flips from an empty circle to a check as a requirement is met.

When not to use

  • For a ready-made copy action with its tooltip and timing. Use Copy button
  • For an icon button with a pressed state and tooltip built in. Use Icon action
  • For a button that shows progress while it saves. The label stays and a spinner sits on top. Use Pending button
  • For a status that needs words, such as a supplier's sync state. Use Status label
  • For two states of one line icon: a menu to a close, a plus to a minus, a chevron flipping. The strokes move instead of cross-fading. Use Icon morph
  • For the label beside the icon. Swap it with Text swap, keyed on the same state. Use Text swap
  • For decorative motion on hover or load. Icons change only when state does.

Motion grammar

Movement explains a change. The swap runs on a 0.3s spring with no bounce, only on a state change, never on mount, and only animates opacity, scale and blur.

The Label-Beside-Color Rule

An icon alone doesn't carry state. The control says it in words: aria-pressed, an aria-label that changes, a tooltip, or a label beside it.

Anatomy#

  1. Cell. An inline-grid span that centers its children and stacks them in one grid area, so the outgoing and incoming icons overlap exactly.
  2. Outgoing icon. Fades out, shrinks to 0.25 and blurs by 4px, popped out of layout so it doesn't push anything.
  3. Incoming icon. Fades in from 0.25 scale and 4px blur to full size and sharp.

Examples#

Toggles

Favorite, play and pause, show and hide. Toggles keep one label and set aria-pressed; play and pause changes its label because the action itself changes.

•••• 0089
import { Button } from "@oration/canon/components/button";import { IconSwap } from "@oration/canon/components/icon-swap";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { cn } from "@oration/canon/lib/utils";import { EyeIcon, EyeOffIcon, PauseIcon, PlayIcon, StarIcon } from "lucide-react";import * as React from "react";export function Toggles() {    const [favorite, setFavorite] = React.useState(true);    const [playing, setPlaying] = React.useState(false);    const [visible, setVisible] = React.useState(false);    return (        <>            <Tooltip>                <TooltipTrigger                    render={                        <Button                            type="button"                            variant="outline"                            size="icon"                            aria-label="Favorite"                            aria-pressed={favorite}                            onClick={() => setFavorite((value) => !value)}                        />                    }                >                    <IconSwap swapKey={favorite}>                        <StarIcon                            aria-hidden="true"                            className={cn(                                favorite                                    ? "fill-warning text-warning"                                    : "text-muted-foreground",                            )}                        />                    </IconSwap>                </TooltipTrigger>                <TooltipContent>                    {favorite ? "Remove favorite" : "Favorite"}                </TooltipContent>            </Tooltip>            <Tooltip>                <TooltipTrigger                    render={                        <Button                            type="button"                            variant="outline"                            size="icon"                            aria-label={                                playing ? "Pause recording" : "Play recording"                            }                            onClick={() => setPlaying((value) => !value)}                        />                    }                >                    <IconSwap swapKey={playing}>                        {playing ? (                            <PauseIcon aria-hidden="true" />                        ) : (                            <PlayIcon aria-hidden="true" />                        )}                    </IconSwap>                </TooltipTrigger>                <TooltipContent>                    {playing ? "Pause recording" : "Play recording"}                </TooltipContent>            </Tooltip>            <Tooltip>                <TooltipTrigger                    render={                        <Button                            type="button"                            variant="outline"                            size="icon"                            aria-label="Show account number"                            aria-pressed={visible}                            onClick={() => setVisible((value) => !value)}                        />                    }                >                    <IconSwap swapKey={visible}>                        {visible ? (                            <EyeOffIcon aria-hidden="true" />                        ) : (                            <EyeIcon aria-hidden="true" />                        )}                    </IconSwap>                </TooltipTrigger>                <TooltipContent>                    {visible ? "Hide account number" : "Show account number"}                </TooltipContent>            </Tooltip>            <span className="w-28 font-mono text-13 text-foreground tabular-nums">                {visible ? "021000089" : "•••• 0089"}            </span>        </>    );}

Confirmation in a button

Inside a labelled button the icon swaps in place of the plus, marked data-icon="inline-start" so the button keeps its padding.

  • Priya Raman
  • Tomás Ferreira
  • Aisha Bello
import { Button } from "@oration/canon/components/button";import { IconSwap } from "@oration/canon/components/icon-swap";import { toast } from "@oration/canon/components/toast";import { CheckIcon, PlusIcon } from "lucide-react";import * as React from "react";export function Confirmation() {    const people = ["Priya Raman", "Tomás Ferreira", "Aisha Bello"];    const [added, setAdded] = React.useState<string[]>([]);    return (        <ul className="flex w-full max-w-xs flex-col">            {people.map((person) => {                const isAdded = added.includes(person);                return (                    <li                        key={person}                        className="flex h-10 items-center justify-between gap-3 border-b border-border last:border-b-0"                    >                        <span className="text-13 text-foreground">                            {person}                        </span>                        <Button                            type="button"                            variant="outline"                            size="sm"                            aria-pressed={isAdded}                            onClick={() => {                                setAdded((list) =>                                    isAdded                                        ? list.filter((p) => p !== person)                                        : [...list, person],                                );                                if (!isAdded) {                                    toast.add({                                        title: `${person} added to approvers`,                                        type: "success",                                    });                                }                            }}                        >                            <IconSwap swapKey={isAdded}>                                {isAdded ? (                                    <CheckIcon                                        data-icon="inline-start"                                        aria-hidden="true"                                    />                                ) : (                                    <PlusIcon                                        data-icon="inline-start"                                        aria-hidden="true"                                    />                                )}                            </IconSwap>                            Approver                        </Button>                    </li>                );            })}        </ul>    );}

Pending to done

swapKey can be any string, so a three-state status moves from an empty circle to a spinner to a check, with the words beside it in a status region.

Domain not checked yet
import { Button } from "@oration/canon/components/button";import { IconSwap } from "@oration/canon/components/icon-swap";import { Spinner } from "@oration/canon/components/spinner";import { CheckIcon, CircleIcon } from "lucide-react";import * as React from "react";export function PendingToDone() {    const [status, setStatus] = React.useState<"idle" | "checking" | "done">(        "idle",    );    React.useEffect(() => {        if (status !== "checking") return;        const id = window.setTimeout(() => setStatus("done"), 1400);        return () => window.clearTimeout(id);    }, [status]);    return (        <div className="flex w-full max-w-sm flex-col gap-3">            <div className="flex items-center gap-2 text-13">                <IconSwap swapKey={status}>                    {status === "checking" ? (                        <Spinner                            aria-hidden="true"                            className="size-4 text-muted-foreground"                        />                    ) : status === "done" ? (                        <CheckIcon                            aria-hidden="true"                            className="size-4 text-success"                        />                    ) : (                        <CircleIcon                            aria-hidden="true"                            className="size-4 text-subtle-foreground"                        />                    )}                </IconSwap>                <span role="status" className="text-foreground">                    {status === "checking"                        ? "Checking cedarline.com"                        : status === "done"                          ? "cedarline.com is verified"                          : "Domain not checked yet"}                </span>            </div>            <div>                <Button                    type="button"                    variant="outline"                    size="sm"                    disabled={status === "checking"}                    onClick={() => setStatus("checking")}                >                    {status === "done" ? "Check again" : "Check domain"}                </Button>            </div>        </div>    );}

Requirement checklist

Each requirement flips from a circle to a check as it is met, the way the password field does at sign-up. The met state is also in screen reader text.

  • 12 characters, not met
  • A number, not met
  • A symbol, not met
import { IconSwap } from "@oration/canon/components/icon-swap";import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import { CheckIcon, CircleIcon } from "lucide-react";import * as React from "react";export function Checklist() {    const [password, setPassword] = React.useState("cedar");    const requirements = [        { label: "12 characters", met: password.length >= 12 },        { label: "A number", met: /\d/.test(password) },        { label: "A symbol", met: /[^A-Za-z0-9]/.test(password) },    ];    const id = React.useId();    return (        <div className="flex w-full max-w-xs flex-col gap-2">            <Label htmlFor={id}>New password</Label>            <Input                id={id}                type="text"                autoComplete="off"                value={password}                onChange={(event) => setPassword(event.target.value)}            />            <ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs">                {requirements.map((requirement) => (                    <li                        key={requirement.label}                        className="flex items-center gap-1.5"                    >                        <IconSwap swapKey={requirement.met}>                            {requirement.met ? (                                <CheckIcon                                    aria-hidden="true"                                    className="size-3.5 text-success"                                />                            ) : (                                <CircleIcon                                    aria-hidden="true"                                    className="size-3.5 text-subtle-foreground"                                />                            )}                        </IconSwap>                        <span                            className={                                requirement.met                                    ? "text-foreground"                                    : "text-muted-foreground"                            }                        >                            {requirement.label}                            <span className="sr-only">                                {requirement.met ? ", met" : ", not met"}                            </span>                        </span>                    </li>                ))}            </ul>        </div>    );}

States#

States
StateTreatment
RestOne icon, no motion. The first render never animates.
SwappingWhen swapKey changes, the old icon exits and the new one enters at the same time on a 0.3s spring with no bounce.
Same keyIf the children change but swapKey doesn't, the icon is replaced with no animation.
Reduced motionUnder the app's MotionConfig reducedMotion="user", the scale is dropped and the icons fade.

Behavior#

  • swapKey is turned into a string and used as the React key of the animated span. Change it whenever the icon should change, and pass the icon for the current state as children.
  • AnimatePresence runs with initial={false}, so nothing animates on mount, and mode="popLayout", so the exiting icon leaves the layout at once.
  • Enter and exit are opacity 0 to 1, scale 0.25 to 1 and blur 4px to 0, on a spring with duration: 0.3 and bounce: 0.
  • Rapid toggles interrupt cleanly: the latest key wins and the previous icon exits from wherever it was.
  • It adds no semantics and no size. The icon's own size-* class decides the cell, and the parent button decides the hit area.
  • It is used inside Copy button, Password input and the AI message copy action, so those swap the same way.

Do and don't#

Do. Key the swap on the state that picks the icon, so the change is animated.
Don't. Swap the icon with a plain conditional. It snaps, and the change is easy to miss.
Do. Expose the state on the control: aria-pressed on a toggle, a label that changes from Copy to Copied.
Don't. Let the icon be the only sign of the state. Screen readers hear nothing, and a filled star alone is easy to misread.

Content#

  • The control's label names the action for the current state: Play recording and Pause recording, not Toggle.
  • For a toggle, keep one label and set aria-pressed: Favorite, pressed or not.
  • Confirmations are past tense and short: Copied, Added, Saved. Keep them in the tooltip or a live region, not a toast, for a one-click action.

Accessibility#

  • Icon swap renders two <span>s with no role. Mark the icons aria-hidden="true" and name the control around it.
  • Toggles use aria-pressed. Actions whose meaning flips (play and pause) change their aria-label.
  • Transient confirmations like Copied are announced with a polite live region, or through a tooltip that updates.
  • Under reduced motion the swap is a short fade; the scale is removed by the app's Motion config.
  • The hit area belongs to the button, not the icon: at least 24px, 32px on touch.

Design tokens#

Design tokens
TokenUsed for
spring, duration 0.3, bounce 0Enter and exit timing
opacity, scale 0.25, blur 4pxThe enter and exit states
inline-grid, [grid-area:1/1]Stacks both icons in one cell

API reference#

IconSwap

A cross-fade wrapper for the icon of the current state.

Other props spread onto Nothing. Only the props below are used..

Props of IconSwap
PropTypeDefaultDescription
swapKeyRequiredstring | number | booleanNo defaultChanging it animates to the new children. Usually the state itself.
childrenRequiredReact.ReactNodeNo defaultThe icon for the current state.
classNamestringNo defaultOn the outer cell, merged after inline-grid place-items-center.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

It relies on the app-wide MotionConfig reducedMotion="user" for reduced motion, which removes the scale but keeps the blur. DESIGN.md asks for blur to be zeroed too, and outside that provider the scale runs regardless.

The inner span is fixed at inline-flex with no way to pass classes or attributes to it.