Skip to content

Text swap

A short label that changes in place: shared letters glide, new ones sharpen in, and the width follows.

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

Invoice reminders

Active
import { Button } from "@oration/canon/components/button";import { IconSwap } from "@oration/canon/components/icon-swap";import { StatusLabel } from "@oration/canon/components/status-dot";import { TextSwap } from "@oration/canon/components/text-swap";import { BellIcon, BellOffIcon, PauseIcon, PlayIcon } from "lucide-react";import * as React from "react";export function Hero() {    const [following, setFollowing] = React.useState(false);    const [paused, setPaused] = React.useState(false);    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 gap-1">                <p className="text-sm font-medium text-foreground">                    Invoice reminders                </p>                <StatusLabel tone={paused ? "neutral" : "primary"}>                    <TextSwap>{paused ? "Paused" : "Active"}</TextSwap>                </StatusLabel>            </div>            <Button                variant="outline"                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>            <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>        </div>    );}

Usage#

Text swap changes a short label in place. Letters the old and new label share glide to their new spots, new letters sharpen in, removed letters blur away, and the width follows on a spring, so the button or row around it never jumps. Reach for it whenever a label changes while people are looking at it: Follow to Unfollow, Check DNS to Checking, Pause to Resume. The mistake is using it for text that never changes on screen (a dialog title, a plural, an empty state) or for a sentence that can wrap.

When to use

  • For a button whose label flips with its state: Pause and Resume, Follow and Unfollow, Show and Hide.
  • For a pending label that replaces the action while it runs: Send test to Sending, Test run to Running.
  • For a short status that changes live: a status label going from On call to On hold, Active to Paused.
  • For a confirmation that replaces the action in the same spot: Add to Added, Verify to Verified.

When not to use

  • For a number that changes. Counts, amounts and credits roll in tabular figures. Use Animated number
  • For the icon beside the label. Swap it with Icon swap, keyed on the same state. Use Icon swap
  • For a label that is being worked out, such as Summarizing… in a shimmer. The shimmer paints through the text, and moving letters break it. Use Live and running states
  • For text decided once when a view opens: Edit view or New view, an empty state, a plural such as 1 agent or 3 agents, or a tooltip. It never changes on screen, so there is nothing to animate.
  • For a sentence or anything that can wrap. Text swap stays on one line.

The Swap-In-Place Rule

A label or icon that changes while people watch changes in place: icons through Icon swap or Icon morph, short labels through Text swap, numbers through Animated number. It never snaps with a plain conditional.

The Three Springs Rule

Letters and the width move on spring.moderate, which is critically damped, so nothing overshoots the layout. Leaving letters use exit.moderate, one tier quicker.

Anatomy#

  1. Root. The as element (a span by default), whitespace-nowrap, carrying className and id.
  2. Screen reader text. An sr-only copy of the plain label, so assistive technology reads Unfollow, never letters.
  3. Frame. An aria-hidden inline block with an explicit width that springs to the new label's width.
  4. Track and letters. One inline span per character. Letters are matched by character and occurrence, so the second i in one label pairs with the second i in the next.

Examples#

Pending label

The label and its icon change together, keyed on the same state: Check DNS to Checking with the refresh icon swapping to a spinner.

cedarline.com
import { Button } from "@oration/canon/components/button";import { IconSwap } from "@oration/canon/components/icon-swap";import { Spinner } from "@oration/canon/components/spinner";import { TextSwap } from "@oration/canon/components/text-swap";import { RefreshCwIcon } from "lucide-react";import * as React from "react";export function Pending() {    const [checking, setChecking] = React.useState(false);    React.useEffect(() => {        if (!checking) return;        const id = window.setTimeout(() => setChecking(false), 1600);        return () => window.clearTimeout(id);    }, [checking]);    return (        <div className="flex items-center gap-3">            <Button                variant="outline"                disabled={checking}                onClick={() => setChecking(true)}            >                <IconSwap swapKey={checking}>                    {checking ? (                        <Spinner data-icon="inline-start" />                    ) : (                        <RefreshCwIcon                            data-icon="inline-start"                            aria-hidden="true"                        />                    )}                </IconSwap>                <TextSwap>{checking ? "Checking" : "Check DNS"}</TextSwap>            </Button>            <span className="text-13 text-muted-foreground">cedarline.com</span>        </div>    );}

Three states

Labels that share letters morph best. Publish, Publishing and Published keep their stem and only the ending moves.

import { Button } from "@oration/canon/components/button";import { TextSwap } from "@oration/canon/components/text-swap";import * as React from "react";export function ThreeStates() {    const [step, setStep] = React.useState(0);    React.useEffect(() => {        if (step !== 1) return;        const id = window.setTimeout(() => setStep(2), 1200);        return () => window.clearTimeout(id);    }, [step]);    return (        <Button            disabled={step === 1}            onClick={() => setStep(step === 2 ? 0 : 1)}        >            <TextSwap>{steps[step] ?? "Publish"}</TextSwap>        </Button>    );}

States#

States
StateTreatment
RestThe label as plain text. The first render never animates.
SwappingShared letters glide, new letters rise 0.14em from 80% scale and a 2px blur, removed letters lift 0.1em and blur away, and the frame springs to the new width.
Rapid changeA new label mid-swap starts from wherever the letters are. The latest label wins.
Reduced motionReads useReducedMotion. The label and width change at once, with no blur, scale or travel.

Behavior#

  • Pass the label for the current state as a single string: a ternary of string literals or a template literal. JSX children aren't allowed.
  • Only the letters that are new stagger, in reading order, 20ms apart and capped at eight letters, after a 60ms handoff that lets leaving letters start to clear.
  • Leaving letters pop out of layout (AnimatePresence mode="popLayout"), so the new label measures its true width at once.
  • A ResizeObserver follows width changes that aren't a new label (fonts loading, a responsive size) without animating.
  • It inherits font, size, weight and color, so it works inside Button, Pending button, Status label, headings and table cells unchanged.
  • Pair it with Icon swap when the same control also changes its icon, keyed on the same state.

Do and don't#

Do. Wrap the ternary that picks the label, so the change reads as one control changing state.
Don't. Swap the label with a plain conditional. The button snaps to a new width and the change is easy to miss.

Dialog titles, empty states and plural counts render as plain text.

Do. Keep it to short labels that change on screen, and leave decided-once text as plain text.

Wrapping a sentence that can break across lines

Don't. Wrap a sentence, a dialog title or a plural. It can't wrap and there's nothing to animate.

Content#

  • Pending labels are the verb in the present participle with no ellipsis when a spinner travels with them: Checking, Sending, Publishing.
  • Confirmations are past tense and short: Added, Verified, Published.
  • Toggles name the action the button takes next: Pause while running, Resume while paused.
  • Labels that share letters morph best (Publish, Publishing, Published). Labels with none still cross-fade cleanly.

Accessibility#

  • The visual letters are aria-hidden="true"; an sr-only copy carries the label, so a button's accessible name is the whole word.
  • Text swap adds no live region. If the change must be announced (a result, not an action label), put the message in a polite role="status" region too.
  • Toggles still set aria-pressed where the label doesn't change, and change their label where the action does.
  • Under reduced motion the label changes at once.

Design tokens#

Design tokens
TokenUsed for
spring.moderateLetter glide, letter enter and width
exit.moderateLeaving letters, one tier quicker
exit.moderate.duration / 2Handoff before the first new letter
opacity, scale 0.8, 0.14em rise, blur 2pxThe enter and exit states

API reference#

TextSwap

A short label that swaps in place when it changes.

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

Props of TextSwap
PropTypeDefaultDescription
childrenRequiredstringNo defaultThe label for the current state.
as"span" | "div" | "p" | "strong" | "h1" | "h2" | "h3""span"The root element.
classNamestringNo defaultOn the root, merged after whitespace-nowrap.
idstringNo defaultOn the root.

Known gaps#

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

Splitting the label into letters drops kerning pairs and ligatures between them. At 12 to 14px Geist this is invisible; at display sizes check it.

It can't sit inside text-shimmer: the gradient is clipped to text, and moving letters drop out of it. In-progress shimmer labels stay plain text.