Swap Button for React

Shadcn button that swaps two labels (and optional icons) with a crossfade, with no width jump. Drop-in on top of Button.

Component

Installation

 

Usage

Import

Import along with a boolean for swap state (or use a server action, etc.).

import { useState } from "react";
import { SwapButton } from "@/components/ui/swap-button";

Wire state

Wire swapped to your state. Optional icon / iconSwapped for leading icons per state.

const [on, setOn] = useState(false);
// ...
<SwapButton
  swapped={on}
  label1="Before"
  label2="After"
  onClick={() => setOn((o) => !o)}
/>;

Guidelines

  • Control visibility with the swapped boolean: true shows label2, false shows label1.
  • With icons, the leading icon is icon when not swapped; when swapped, uses iconSwapped if set, else icon.
  • Extends the shadcn Button; pass variant, size, asChild, className, onClick, and other native button props as usual.

Props

All props are optional unless marked required. Use these to customize every aspect of the component.

PropTypeDefaultDescription
swappedrequiredboolean-When true, label2 is visible; when false, label1.
label1string-First label (visible when not swapped).
label2string-Second label (visible when swapped).
iconReact.ReactNode-Leading content when not swapped. Often a lucide icon.
iconSwappedReact.ReactNodeiconLeading content when swapped. Falls back to icon if omitted.
labelClassNamestring-Class names applied to both label spans (text styling).
Button & React.ButtonHTMLAttributes<HTMLButtonElement>-Inherits shadcn Button props: variant, size, asChild, className, disabled, onClick, type, etc.

Accessibility

  • Renders a real native button via the shadcn Button, so it is fully keyboard operable with Tab to focus and Enter or Space to activate, and inherits disabled, type, and onClick.
  • Focus is clearly visible because the Button base applies focus-visible outline and ring styles, so keyboard users always see where they are.
  • Both the label1 and label2 spans stay mounted in the DOM with only opacity toggled, and neither hidden span is marked aria-hidden, so a screen reader announces both labels at once; add aria-hidden to the inactive span to fix this.
  • The swapped state is purely visual and is not exposed to assistive tech, so consider adding aria-pressed or an aria-live label so non-sighted users learn the state changed.
  • It does not check prefers-reduced-motion, so the opacity crossfade runs for everyone; guard the transition so users who prefer reduced motion get an instant swap instead of a fade.

Performance

  • Only the opacity property animates via a CSS transition, which is a compositor-friendly property that does not trigger layout or paint, so the crossfade is cheap and GPU accelerated.
  • Width stays stable without measuring or JavaScript because the longer label remains in normal flow while the shorter label is positioned absolute, avoiding any layout thrash on swap.
  • There is no requestAnimationFrame, IntersectionObserver, timer, or event listener, so there is nothing to schedule or clean up and the component stays purely declarative.
  • Both label spans are always rendered so the DOM cost is two spans regardless of state, and there is no will-change hint, which is fine for an occasional toggle but could be added if many instances animate together.

Examples

Basic

Labels only. Width follows the longer string.

import { useState } from "react";
import { SwapButton } from "@/components/ui/swap-button";

export function SaveExample() {
  const [isSaved, setIsSaved] = useState(false);
  return (
    <SwapButton
      swapped={isSaved}
      label1="Save"
      label2="Saved"
      onClick={() => setIsSaved((s) => !s)}
    />
  );
}

With icons

Optional icon + iconSwapped for different icons per state.

import { useState } from "react";
import { Check, Mail } from "lucide-react";
import { SwapButton } from "@/components/ui/swap-button";

export function NotifyExample() {
  const [subscribed, setSubscribed] = useState(false);
  return (
    <SwapButton
      swapped={subscribed}
      label1="Subscribe"
      label2="Subscribed"
      icon={<Mail className="size-4" />}
      iconSwapped={<Check className="size-4" />}
      onClick={() => setSubscribed((s) => !s)}
    />
  );
}

Outline

Any Button variant (default, outline, ghost, link, …).

import { useState } from "react";
import { SwapButton } from "@/components/ui/swap-button";

export function FollowExample() {
  const [following, setFollowing] = useState(false);
  return (
    <SwapButton
      variant="outline"
      swapped={following}
      label1="Follow"
      label2="Following"
      onClick={() => setFollowing((f) => !f)}
    />
  );
}

Last updated on Jun 19

Made with ❤️ by Pulkit &

© 2026 Pulkit. All rights reserved

Last updated: