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
"use client";
import type { VariantProps } from "class-variance-authority";
import * as React from "react";
import {
Button,
type buttonVariants,
} from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface SwapButtonProps
extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"children"
>,
VariantProps<typeof buttonVariants> {
swapped: boolean;
label1: string;
label2: string;
icon?: React.ReactNode;
iconSwapped?: React.ReactNode;
labelClassName?: string;
}
const SPAN_BASE =
"flex items-center justify-center gap-2 transition-opacity";
const SwapButton = React.forwardRef<
HTMLButtonElement,
SwapButtonProps
>(
(
{
swapped,
label1,
label2,
icon,
iconSwapped,
labelClassName,
className,
...props
},
ref,
) => {
const label1IsLonger = label1.length >= label2.length;
const hasIcon =
icon !== undefined || iconSwapped !== undefined;
const currentIcon =
icon === undefined
? null
: swapped
? (iconSwapped ?? icon)
: icon;
return (
<Button
ref={ref}
className={cn(
"relative select-none",
hasIcon && "flex justify-start",
className,
)}
{...props}
>
<span
className={cn(
SPAN_BASE,
!label1IsLonger && "absolute",
swapped ? "opacity-0" : "opacity-100",
labelClassName,
)}
>
{currentIcon}
{label1}
</span>
<span
className={cn(
SPAN_BASE,
label1IsLonger && "absolute",
swapped ? "opacity-100" : "opacity-0",
labelClassName,
)}
>
{currentIcon}
{label2}
</span>
</Button>
);
},
);
SwapButton.displayName = "SwapButton";
export { SwapButton };Installation
1. Ensure shadcn Button is present
npx shadcn@latest add button2. Copy the component file
"use client";
import type { VariantProps } from "class-variance-authority";
import * as React from "react";
import {
Button,
type buttonVariants,
} from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface SwapButtonProps
extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"children"
>,
VariantProps<typeof buttonVariants> {
swapped: boolean;
label1: string;
label2: string;
icon?: React.ReactNode;
iconSwapped?: React.ReactNode;
labelClassName?: string;
}
const SPAN_BASE =
"flex items-center justify-center gap-2 transition-opacity";
const SwapButton = React.forwardRef<
HTMLButtonElement,
SwapButtonProps
>(
(
{
swapped,
label1,
label2,
icon,
iconSwapped,
labelClassName,
className,
...props
},
ref,
) => {
const label1IsLonger = label1.length >= label2.length;
const hasIcon =
icon !== undefined || iconSwapped !== undefined;
const currentIcon =
icon === undefined
? null
: swapped
? (iconSwapped ?? icon)
: icon;
return (
<Button
ref={ref}
className={cn(
"relative select-none",
hasIcon && "flex justify-start",
className,
)}
{...props}
>
<span
className={cn(
SPAN_BASE,
!label1IsLonger && "absolute",
swapped ? "opacity-0" : "opacity-100",
labelClassName,
)}
>
{currentIcon}
{label1}
</span>
<span
className={cn(
SPAN_BASE,
label1IsLonger && "absolute",
swapped ? "opacity-100" : "opacity-0",
labelClassName,
)}
>
{currentIcon}
{label2}
</span>
</Button>
);
},
);
SwapButton.displayName = "SwapButton";
export { SwapButton };3. Import and use
import { useState } from "react";
import { SwapButton } from "@/components/ui/swap-button";
export function Example() {
const [on, setOn] = useState(false);
return (
<SwapButton
swapped={on}
label1="Off"
label2="On"
onClick={() => setOn((v) => !v)}
/>
);
}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.
| Prop | Type | Default | Description |
|---|---|---|---|
| swappedrequired | boolean | - | When true, label2 is visible; when false, label1. |
| label1 | string | - | First label (visible when not swapped). |
| label2 | string | - | Second label (visible when swapped). |
| icon | React.ReactNode | - | Leading content when not swapped. Often a lucide icon. |
| iconSwapped | React.ReactNode | icon | Leading content when swapped. Falls back to icon if omitted. |
| labelClassName | string | - | 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)}
/>
);
}Related reading
- Smooth Operators — The crossfade transition behind the label swap
- When to Animate and When to Skip — Micro-interactions that aid feedback
- The Psychology of Motion in UI — Why state-change motion reassures users
Last updated on Jun 19