All patterns

Regenerate Swap

The old answer dissolves upward while the new one rises into the space it left.

aicalmfriendlyinteraction · finite · intermediate · ~0.4s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

211 lines · react + motion only
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Regenerate Swap
 *
 * Asking for another answer: the old one dissolves upward while the new
 * one rises into the space it just left, so the reader can see that a
 * replacement happened rather than a repaint.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so it reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `answers`, `label`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type RegenerateSwapProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Answers cycled through, one per press. */
  answers?: string[];
  /** Button text. */
  label?: string;
  /** Floor for the answer area, so short answers do not collapse it. */
  minHeight?: number;
  /** Accent for the button icon. */
  color?: string;
};

type VariantConfig = {
  /** px the incoming answer rises from, and the outgoing one leaves by. */
  travel: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  /** Fade of the outgoing answer — always shorter than the entrance. */
  exitSeconds: number;
  /** Seconds for one turn of the button icon. */
  turnSeconds: number;
};

// Damping ratios (ζ = damping / 2√stiffness) stay at or above 0.8. The
// thing arriving is a paragraph someone is about to read; a bouncing
// paragraph costs a re-read every single time it is regenerated.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // ζ ≈ 1.07 — a near-straight crossfade. For answers regenerated often.
  subtle: {
    travel: 6,
    spring: { type: "spring", stiffness: 460, damping: 46 },
    exitSeconds: 0.14,
    turnSeconds: 0.45,
  },
  // ζ ≈ 0.93 — lands clean. The all-purpose setting.
  default: {
    travel: 11,
    spring: { type: "spring", stiffness: 420, damping: 38 },
    exitSeconds: 0.18,
    turnSeconds: 0.55,
  },
  // ζ ≈ 0.82 — one soft settle and more travel, for a single hero answer.
  playful: {
    travel: 16,
    spring: { type: "spring", stiffness: 380, damping: 32 },
    exitSeconds: 0.2,
    turnSeconds: 0.65,
  },
};

const SAMPLE_ANSWERS = [
  "Refunds go back to the card that was charged. Most banks post them within five business days.",
  "We return the money to the original payment method — usually visible on the statement in five business days.",
  "The charge is reversed on the card you paid with, and it typically clears within five business days.",
];

/** Theme-adaptive neutral: `currentColor` is the text color this
 *  component inherits — near-black on a light page, near-white on a dark
 *  one — so mixing it with `transparent` yields a surface, border or fill
 *  that is correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function RegenerateSwap({
  variant = "default",
  answers = SAMPLE_ANSWERS,
  label = "Regenerate",
  minHeight = 64,
  color = "#7C7CF0",
}: RegenerateSwapProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [turn, setTurn] = useState(0);

  const index = turn % answers.length;

  return (
    <div
      style={{
        width: 320,
        padding: 16,
        borderRadius: 16,
        background: tone(5),
        border: `1px solid ${tone(11)}`,
      }}
    >
      {/* Both answers occupy the same grid cell during the swap, so the
          surrounding page never reflows mid-crossfade. */}
      <div style={{ display: "grid", minHeight }}>
        <AnimatePresence initial={false}>
          <motion.p
            key={index}
            // Reduced motion: the same replacement, carried by opacity
            // alone. Nothing travels, the answer still changes.
            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.travel }}
            animate={{ opacity: 1, y: 0 }}
            // The outgoing answer leaves faster than the new one lands,
            // so the two never sit at half opacity together for long.
            exit={{
              opacity: 0,
              y: reduceMotion ? 0 : -cfg.travel,
              transition: { duration: cfg.exitSeconds, ease: "easeIn" },
            }}
            transition={{
              y: cfg.spring,
              opacity: { duration: 0.24, ease: "easeOut" },
            }}
            style={{
              gridArea: "1 / 1",
              margin: 0,
              fontSize: 13.5,
              lineHeight: 1.6,
              opacity: 0.84,
            }}
          >
            {answers[index]}
          </motion.p>
        </AnimatePresence>
      </div>

      <div
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          gap: 12,
          marginTop: 14,
          paddingTop: 12,
          borderTop: `1px solid ${tone(11)}`,
        }}
      >
        <button
          type="button"
          onClick={() => setTurn((value) => value + 1)}
          style={{
            display: "inline-flex",
            alignItems: "center",
            gap: 7,
            padding: "6px 11px 6px 9px",
            borderRadius: 999,
            background: tone(7),
            border: `1px solid ${tone(12)}`,
            color: "inherit",
            font: "inherit",
            fontSize: 12.5,
            fontWeight: 600,
            cursor: "pointer",
          }}
        >
          {/* The icon turns once per press. Icons may rotate; the label
              beside it holds perfectly still. */}
          <motion.span
            aria-hidden
            animate={{ rotate: turn * -360 }}
            transition={
              reduceMotion
                ? { duration: 0 }
                : { duration: cfg.turnSeconds, ease: [0.22, 1, 0.36, 1] }
            }
            style={{ display: "inline-flex", color }}
          >
            <svg width="13" height="13" viewBox="0 0 14 14" fill="none">
              <path
                d="M12 7a5 5 0 1 1-1.6-3.7"
                stroke="currentColor"
                strokeWidth="1.6"
                strokeLinecap="round"
              />
              <path
                d="M12.2 1.6v3.1H9.1"
                stroke="currentColor"
                strokeWidth="1.6"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
          </motion.span>
          {label}
        </button>

        <span
          aria-live="polite"
          style={{
            fontSize: 11.5,
            opacity: 0.45,
            fontVariantNumeric: "tabular-nums",
          }}
        >
          Answer {index + 1} of {answers.length}
        </span>
      </div>
    </div>
  );
}

About this pattern

Regenerating produces two texts that look alike, and without motion the reader cannot tell whether anything actually changed. Directional exchange fixes that: the previous answer leaves upward on a short ease-in while the replacement rises from below on a tightly damped spring, so the swap is legible as a swap. Both occupy the same grid cell, which keeps the page from reflowing mid-crossfade, and the outgoing text clears faster than the incoming text lands so the two are never both half-visible for long.

Regenerate a responseCycle through draftsRetry a generationAlternative answer

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Summarise the supplier contract and flag anything unusual.
    The renewal runs another twelve months at the same rate, with one clause worth a second look.
    Ask a follow-up
    AI assistant

    Regenerating a reply exchanges the previous text in place, with a counter for which version is shown.

Related patterns