Labels and icons that change
When a label, icon or number changes on screen it changes in place: Text swap, Icon swap, Icon morph or Animated number, never a snap.
The problem#
Controls change their words and icons all the time: Pause becomes Resume, Check DNS becomes Checking, a bell becomes a muted bell, a plus becomes a close.
Written as a plain conditional, every one of those changes snaps. The button jumps to a new width, the text beside it shifts, and the icon blinks. People miss that anything happened, or read the jump as a glitch. Across a few hundred conditionals in the app, it adds up to an interface that jitters every time it answers back.
The solution#
Anything that changes while people watch changes in place, through one of four components. Everything else stays plain text.
| What changes on screen | Use | Example |
|---|---|---|
| A short label (one to four words) | Text swap | Pause to Resume, Send test to Sending, Add to Added, a status label from Active to Paused |
| Two different icons | Icon swap | Copy to check, play to pause, a spinner to a refresh icon, an eye to an eye-off |
| Two states of one line icon | Icon morph | Menu to close, plus to close or minus, a chevron flipping, an arrow turning |
| A number | Animated number | Credits counting down, 128 runs becoming 129 |
| A label being worked out | text-shimmer, no swap | Summarizing…, Finding email… (see Live states) |
The Label-Beside-Color Rule
The Three Springs Rule
spring.fast, spring.moderate and spring.slow, with exits one tier faster that never bounce. No other timings exist except the skeleton reveal, shimmer, the voice orb and meter fills, and keyboard-driven surfaces don't animate.The Swap-In-Place Rule
TextSwap, two different icons through IconSwap, two states of one line icon through IconMorph, and figures through AnimatedNumber. Text decided once (dialog titles, plurals, empty states), sentences, tooltips, menu items and text-shimmer labels stay plain.One control, one state#
When a control changes its icon and its label together, both swap on the same state. Status words, counts and disclosure icons around it follow the same rule.
Agent controls
Pause, test-run and follow the agent. The status label, the run count, the chevron and every button label change in place.
Supplier onboarding agent
import { AnimatedNumber } from "@oration/canon/components/animated-number";import { Button } from "@oration/canon/components/button";import { IconMorph } from "@oration/canon/components/icon-morph";import { IconSwap } from "@oration/canon/components/icon-swap";import { Spinner } from "@oration/canon/components/spinner";import { StatusLabel } from "@oration/canon/components/status-dot";import { TextSwap } from "@oration/canon/components/text-swap";import { BellIcon, BellOffIcon, FlaskConicalIcon, PauseIcon, PlayIcon } from "lucide-react";import * as React from "react";export function AgentControls() { const [paused, setPaused] = React.useState(false); const [following, setFollowing] = React.useState(false); const [running, setRunning] = React.useState(false); const [runs, setRuns] = React.useState(128); const [open, setOpen] = React.useState(false); React.useEffect(() => { if (!running) return; const id = window.setTimeout(() => { setRunning(false); setRuns((value) => value + 1); }, 1400); return () => window.clearTimeout(id); }, [running]); return ( <div className="flex w-full max-w-xl flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <div className="flex items-start gap-3"> <div className="flex min-w-0 flex-1 flex-col gap-1"> <p className="text-sm font-medium text-foreground"> Supplier onboarding agent </p> <div className="flex items-center gap-3 text-13 text-muted-foreground"> <StatusLabel tone={paused ? "neutral" : "primary"}> <TextSwap>{paused ? "Paused" : "Active"}</TextSwap> </StatusLabel> <span className="tabular-nums"> <AnimatedNumber value={runs} /> runs this week </span> </div> </div> <Button variant="ghost" size="icon-sm" aria-label="More details" aria-expanded={open} onClick={() => setOpen((value) => !value)} > <IconMorph icon={open ? "chevron-up" : "chevron-down"} /> </Button> </div> <div className="flex flex-wrap items-center gap-2"> <Button variant="outline" onClick={() => setPaused((value) => !value)} > <IconSwap swapKey={paused}> {paused ? ( <PlayIcon data-icon="inline-start" aria-hidden="true" /> ) : ( <PauseIcon data-icon="inline-start" aria-hidden="true" /> )} </IconSwap> <TextSwap>{paused ? "Resume" : "Pause"}</TextSwap> </Button> <Button variant="outline" disabled={running} onClick={() => setRunning(true)} > <IconSwap swapKey={running}> {running ? ( <Spinner data-icon="inline-start" /> ) : ( <FlaskConicalIcon data-icon="inline-start" aria-hidden="true" /> )} </IconSwap> <TextSwap>{running ? "Running" : "Test run"}</TextSwap> </Button> <Button variant="ghost" aria-pressed={following} onClick={() => setFollowing((value) => !value)} > <IconSwap swapKey={following}> {following ? ( <BellOffIcon data-icon="inline-start" aria-hidden="true" /> ) : ( <BellIcon data-icon="inline-start" aria-hidden="true" /> )} </IconSwap> <TextSwap>{following ? "Unfollow" : "Follow"}</TextSwap> </Button> </div> </div> );}The recipe#
Key the icon on the state, wrap the label ternary, keep every prop on the icon.
import { IconSwap } from "@oration/canon/components/icon-swap";import { TextSwap } from "@oration/canon/components/text-swap";<Button variant="outline" onClick={togglePaused}> <IconSwap swapKey={paused}> {paused ? ( <PlayIcon data-icon="inline-start" aria-hidden="true" /> ) : ( <PauseIcon data-icon="inline-start" aria-hidden="true" /> )} </IconSwap> <TextSwap>{paused ? "Resume" : "Pause"}</TextSwap></Button>import { IconMorph } from "@oration/canon/components/icon-morph";<Button variant="ghost" size="icon" aria-label="Navigation" aria-expanded={open}> <IconMorph icon={open ? "close" : "menu"} /></Button>IconSwaptakes the state asswapKey; the icon for the current state is its child. Keepdata-icon="inline-start"on the icon itself so Button keeps its padding.TextSwaptakes the label as a single string: a ternary of string literals or a template literal.IconMorphtakes the glyph name for the current state. There's no key to manage.- All three use
spring.moderate, skip the first render and honor reduced motion.
What stays plain#
Most conditional text in the app is decided once and never changes on screen. Animating it adds motion with nothing to explain.
| Kind | Why | Example |
|---|---|---|
| Decided when a view opens | Dialog and sheet titles, create-or-edit buttons and form labels are set before anyone sees them. | Edit view or New view, Save endpoint or Add endpoint |
| Plurals and data per row | Each row renders its own data once. Counts that change go through Animated number. | 1 agent or 3 agents, Inbound or Outbound |
| Empty and error states | They replace the content entirely rather than relabel a control. | No tables match or No tables yet |
| Sentences | Text swap stays on one line and can't wrap. | Help text, descriptions, confirmation copy |
| Tooltips, menu items, aria labels | Menus close on click and tooltips re-render as they open; attributes aren't seen. | Mark as read in a tooltip, Pause in a row menu |
| Shimmer labels | text-shimmer clips a gradient to the text, and moving letters drop out of it. | Summarizing…, Rewriting… |
| Screen reader only | Status regions are heard, not seen. | A role="status" span that says Thinking |
Snap versus swap#
The same button, written two ways.
Auditing a screen#
Before you finish a screen, find every conditional label and icon and decide which ones change on screen.
# Every JSX child that picks between text with a ternary.grep -rnE '\{[^{}]*\? "[^"]+"\s*:\s*"[^"]+"' apps/web/src --include='*.tsx'# Every icon ternary that isn't inside an IconSwap yet.grep -rnE '\? <[A-Z][A-Za-z]*Icon' apps/web/src --include='*.tsx'- For each hit, ask one question: can this change while the element is on screen? A click, a pending state, a live event or a timer means yes.
- Yes and short: wrap it in
TextSwap. Yes and an icon:IconSwap, orIconMorphfor a glyph in its set. Yes and a number:AnimatedNumber. - No, or it's a sentence, tooltip, menu item, shimmer or screen reader text: leave it.
- A control that is remounted when its state changes (for example, wrapped in a Tooltip only while pending) can't animate. Keep the tree stable, or leave it plain.
Swaps don't replace words
An icon swap still needs a label that says the state, and a text swap still needs aria-pressed or aria-expanded where the control is a toggle or disclosure.
Accessibility#
The animation is for sighted people; the state is for everyone.
- Text swap renders an
sr-onlycopy of the label and hides the moving letters, so a button's name is always the whole word. - Icon swap and Icon morph are decorative. Mark the icons
aria-hiddenand name the control. - Toggles set
aria-pressed, disclosures setaria-expanded, and actions whose meaning flips change their label. - None of the three is a live region. Announce results through a polite
role="status"region when people need to hear them. - Under reduced motion every swap is instant: no blur, scale or travel.
Components#
The parts this pattern is built from.
- Text swapA short label that changes in place: shared letters glide, new ones sharpen in, and the width follows.
- Icon swapA cross-fade between two icons in one cell, so a state change never shifts layout.
- Icon morphA line icon whose strokes move into a related glyph: menu to close, plus to minus, a chevron flipping.
- Animated numberA tabular figure that rolls to its new value when it changes.
- Pending buttonA button that keeps its width while a spinner replaces its label.
- Status labelAn 8px dot that always travels with a text label, in neutral, live, success, warning, danger and info tones.