Regenerate Swap
The old answer dissolves upward while the new one rises into the space it left.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
Regenerating a reply exchanges the previous text in place, with a counter for which version is shown.
Related patterns
- Translate CrossfadeCopy hands over to its translation while the block eases to the new text's height.
- Retry Backoff CountdownA ring empties over the wait while the seconds count down to the next automatic attempt.
- Sentiment Tint ShiftEach row's edge and wash ease from neutral toward the tone the classifier detected.