Text swap
A short label that changes in place: shared letters glide, new ones sharpen in, and the width follows.
Invoice reminders
Activeimport { 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
The Three Springs Rule
spring.moderate, which is critically damped, so nothing overshoots the layout. Leaving letters use exit.moderate, one tier quicker.Anatomy#
- Root. The
aselement (aspanby default),whitespace-nowrap, carryingclassNameandid. - Screen reader text. An
sr-onlycopy of the plain label, so assistive technology reads Unfollow, never letters. - Frame. An
aria-hiddeninline block with an explicit width that springs to the new label's width. - 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.
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#
| State | Treatment |
|---|---|
| Rest | The label as plain text. The first render never animates. |
| Swapping | Shared 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 change | A new label mid-swap starts from wherever the letters are. The latest label wins. |
| Reduced motion | Reads 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
ResizeObserverfollows 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#
Dialog titles, empty states and plural counts render as plain text.
Wrapping a sentence that can break across lines
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"; ansr-onlycopy 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-pressedwhere the label doesn't change, and change their label where the action does. - Under reduced motion the label changes at once.
Design tokens#
| Token | Used for |
|---|---|
spring.moderate | Letter glide, letter enter and width |
exit.moderate | Leaving letters, one tier quicker |
exit.moderate.duration / 2 | Handoff before the first new letter |
opacity, scale 0.8, 0.14em rise, blur 2px | The 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..
| Prop | Type | Default | Description |
|---|---|---|---|
childrenRequired | string | No default | The label for the current state. |
as | "span" | "div" | "p" | "strong" | "h1" | "h2" | "h3" | "span" | The root element. |
className | string | No default | On the root, merged after whitespace-nowrap. |
id | string | No default | On 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.