Skip to content

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.

Which component, by what changes
What changes on screenUseExample
A short label (one to four words)Text swapPause to Resume, Send test to Sending, Add to Added, a status label from Active to Paused
Two different iconsIcon swapCopy to check, play to pause, a spinner to a refresh icon, an eye to an eye-off
Two states of one line iconIcon morphMenu to close, plus to close or minus, a chevron flipping, an arrow turning
A numberAnimated numberCredits counting down, 128 runs becoming 129
A label being worked outtext-shimmer, no swapSummarizing…, Finding email… (see Live states)

The Label-Beside-Color Rule

Status is never color alone. A dot, tint or ring always travels with a text label (On track, At risk, Running, Passed), so the state reads in grayscale.

The Three Springs Rule

Motion uses 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

A label, icon or number that changes while people watch changes in place, never with a bare conditional. Short labels go through 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

Active128 runs this week
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.

Toggle button
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>
Disclosure icon
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>
  • IconSwap takes the state as swapKey; the icon for the current state is its child. Keep data-icon="inline-start" on the icon itself so Button keeps its padding.
  • TextSwap takes the label as a single string: a ternary of string literals or a template literal.
  • IconMorph takes 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.

Conditional text that doesn't swap
KindWhyExample
Decided when a view opensDialog 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 rowEach row renders its own data once. Counts that change go through Animated number.1 agent or 3 agents, Inbound or Outbound
Empty and error statesThey replace the content entirely rather than relabel a control.No tables match or No tables yet
SentencesText swap stays on one line and can't wrap.Help text, descriptions, confirmation copy
Tooltips, menu items, aria labelsMenus 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 labelstext-shimmer clips a gradient to the text, and moving letters drop out of it.Summarizing…, Rewriting…
Screen reader onlyStatus regions are heard, not seen.A role="status" span that says Thinking

Snap versus swap#

The same button, written two ways.

Do. Swap the icon and the label in place, keyed on the same state.
Don't. Pick the icon and label with bare conditionals. The button jumps to a new width and the icon blinks.

Auditing a screen#

Before you finish a screen, find every conditional label and icon and decide which ones change on screen.

Find candidates
# 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, or IconMorph for 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-only copy 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-hidden and name the control.
  • Toggles set aria-pressed, disclosures set aria-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.