Swap Button
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
? swapped
? (iconSwapped ?? icon)
: icon
: null;
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
? swapped
? (iconSwapped ?? icon)
: icon
: null;
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. |
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 Apr 26