# Text Swap

Swaps one label for another with a short rise and blur while the width eases to fit: Pay → Processing… → Paid.

**Category:** Transitions  
**Exports:** `TextSwap`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/text-swap.json
```
The file lands at `components/ui/text-swap.tsx`; import from `@/components/ui/text-swap`.

## API — Text Swap
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `children` | `string` | — | The current text. |
| `distance` | `number` | `8` | Vertical travel in px. |

## Example — Text Swap

```tsx
import * as React from "react";
import { TextSwap } from "@/components/ui/text-swap";
import { SpinnerCheck } from "@/components/ui/spinner-check";

const labels = { idle: "Pay $24.00", loading: "Processing payment…", success: "Paid" } as const;

export default function TextSwapDemo({ distance = 8 }: { distance?: number }) {
  const [state, setState] = React.useState<keyof typeof labels>("idle");
  const run = () => {
    if (state !== "idle") return;
    setState("loading");
    setTimeout(() => setState("success"), 1600);
    setTimeout(() => setState("idle"), 3600);
  };
  return (
    <button type="button" onClick={run} className="inline-flex h-11 items-center gap-2 rounded-full bg-foreground px-5 text-sm font-medium text-background">
      {state !== "idle" && <SpinnerCheck state={state} size={16} />}
      <TextSwap distance={distance}>{labels[state]}</TextSwap>
    </button>
  );
}
```

## Source — components/ui/text-swap.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type TextSwapProps = {
  /** The current text. Changing it swaps the old text out and the new text in. */
  children: string;
  /** Vertical travel in px. Positive moves upward. */
  distance?: number;
  className?: string;
};

/**
 * Swaps one label for another with a short rise and a blur, while the width eases to fit,
 * so a button can go "Pay" → "Processing…" → "Paid" without jumping.
 */
export function TextSwap({ children, distance = 8, className }: TextSwapProps) {
  const reduce = useReducedMotion();
  const y = reduce ? 0 : distance;
  return (
    <motion.span layout="size" transition={{ type: "spring", duration: 0.45, bounce: 0.1 }} className={cn("relative inline-flex overflow-hidden whitespace-nowrap", className)}>
      <AnimatePresence mode="popLayout" initial={false}>
        <motion.span
          key={children}
          initial={{ y, opacity: 0, filter: "blur(4px)" }}
          animate={{ y: 0, opacity: 1, filter: "blur(0px)" }}
          exit={{ y: -y, opacity: 0, filter: "blur(4px)" }}
          transition={{ type: "spring", duration: 0.4, bounce: 0 }}
          className="inline-block"
        >
          {children}
        </motion.span>
      </AnimatePresence>
    </motion.span>
  );
}
```