The exchange-swap button for from/to pairs: every press adds a half turn in the same direction with a retargeting spring, so mashing it never stutters.
npx shadcn@latest add @paragon/swap-button"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowDownUp, ArrowLeftRight } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SwapButtonProps
extends Omit<React.ComponentProps<"button">, "children"> {
/** Fires on every activation, after the rotation kicks off. */
onSwap?: () => void;
/** Axis of the exchange — picks the arrow glyph. */
orientation?: "vertical" | "horizontal";
/** Visual size. Hit area stays ≥ 40px either way. */
size?: "sm" | "default";
/** Custom glyph; replaces the built-in arrows. */
icon?: React.ReactNode;
/**
* Masks the button's edge against the surface behind it (the classic
* between-two-fields placement). Uses the background token.
*/
inset?: boolean;
/** Disables the rotation; the callback still fires. */
static?: boolean;
}
/**
* The exchange-swap button that sits between a from/to pair. Every press
* adds a half turn in the same direction — the spring retargets mid-flight,
* so mashing it never stutters — and fires `onSwap` for the caller to flip
* its values. Rotation is cumulative, meaning the glyph always travels
* forward instead of rewinding.
*/
export function SwapButton({
onSwap,
orientation = "vertical",
size = "default",
icon,
inset = false,
static: isStatic = false,
className,
disabled,
onClick,
"aria-label": ariaLabel = "Swap",
...props
}: SwapButtonProps) {
const reduced = useReducedMotion() ?? false;
const [rotation, setRotation] = React.useState(0);
const immediate = isStatic || reduced;
const glyph =
icon ??
(orientation === "vertical" ? (
<ArrowDownUp aria-hidden />
) : (
<ArrowLeftRight aria-hidden />
));
return (
<button
type="button"
data-slot="swap-button"
aria-label={ariaLabel}
disabled={disabled}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
setRotation((r) => r + 180);
onSwap?.();
}}
className={cn(
"group/swap relative inline-flex shrink-0 items-center justify-center rounded-full bg-card text-muted-foreground shadow-border",
"transition-[box-shadow,color,scale] duration-150 ease-out",
"hover:text-foreground hover:shadow-border-hover",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-50",
!isStatic && "active:not-disabled:scale-[0.94]",
size === "sm" ? "size-8" : "size-9",
inset && "ring-4 ring-background",
// 40px hit target regardless of the visual size.
"after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2 after:rounded-full",
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
size === "sm" ? "[&_svg]:size-3.5" : "[&_svg]:size-4",
className,
)}
{...props}
>
<motion.span
className="flex items-center justify-center"
animate={{ rotate: rotation }}
transition={
immediate
? { duration: 0 }
: { type: "spring", duration: 0.45, bounce: 0.15 }
}
>
{glyph}
</motion.span>
</button>
);
}