Icon swap
A cross-fade between two icons in one cell, so a state change never shifts layout.
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
The Label-Beside-Color Rule
aria-pressed, an aria-label that changes, a tooltip, or a label beside it.Anatomy#
- Cell. An
inline-gridspan that centers its children and stacks them in one grid area, so the outgoing and incoming icons overlap exactly. - Outgoing icon. Fades out, shrinks to 0.25 and blurs by 4px, popped out of layout so it doesn't push anything.
- 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.
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.
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#
| State | Treatment |
|---|---|
| Rest | One icon, no motion. The first render never animates. |
| Swapping | When swapKey changes, the old icon exits and the new one enters at the same time on a 0.3s spring with no bounce. |
| Same key | If the children change but swapKey doesn't, the icon is replaced with no animation. |
| Reduced motion | Under the app's MotionConfig reducedMotion="user", the scale is dropped and the icons fade. |
Behavior#
swapKeyis 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 aschildren.AnimatePresenceruns withinitial={false}, so nothing animates on mount, andmode="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.3andbounce: 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#
aria-pressed on a toggle, a label that changes from Copy to Copied.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 iconsaria-hidden="true"and name the control around it. - Toggles use
aria-pressed. Actions whose meaning flips (play and pause) change theiraria-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#
| Token | Used for |
|---|---|
spring, duration 0.3, bounce 0 | Enter and exit timing |
opacity, scale 0.25, blur 4px | The 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..
| Prop | Type | Default | Description |
|---|---|---|---|
swapKeyRequired | string | number | boolean | No default | Changing it animates to the new children. Usually the state itself. |
childrenRequired | React.ReactNode | No default | The icon for the current state. |
className | string | No default | On 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.