Swap Button
Buttons

Swap Button

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.

Install

npx shadcn@latest add @paragon/swap-button

swap-button.tsx

"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>
  );
}