Swap Button
A button whose icon and label change with a state at one fixed width: every face is stacked in one cell and centred, and the new face fades in.
Installation
$
npx shadcn@latest add https://ui.mattdowney.com/r/swap-button.jsonUsage
import { SwapButton } from "@/components/ui/swap-button"Examples
Default
Three faces in one button: press to use the idea, then to copy. The width is the widest face, and each face sits centred in it, so nothing beside the button moves.
Text only
Faces without icons work the same way. Change the variant with the state when the two faces mean different weights.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| states | Record<string, { icon?: IconProp; label: ReactNode }> | - | Every face the button can show, keyed by name. All are rendered, so the button is as wide as the widest. |
| state | string | - | The key of the face showing now. |
| ...props | ButtonProps | - | Every other Button prop (variant, size, onClick, disabled, className, ...) is forwarded. Icons come from the faces, not iconStart. |
"use client"
import * as React from "react"
import { cn } from "@/registry/lib/utils"
import { renderIconProp, type IconProp } from "@/registry/lib/icon-prop"
import { Button, type ButtonProps } from "@/registry/ui/button"
import { buttonGap, buttonIconSidePadding } from "@/registry/ui/button-variants"
type SwapButtonState = {
/** An element (`<IconCopy />`), like every other icon prop in the kit. */
icon?: IconProp
label: React.ReactNode
}
type SwapButtonProps = Omit<ButtonProps, "iconStart" | "iconEnd" | "children"> & {
/** Every face the button can show, keyed by name. All of them are rendered,
* so the button is always as wide as the widest one. */
states: Record<string, SwapButtonState>
/** The key of the face showing now. */
state: string
}
// Every face is an icon-and-label pair in ONE grid cell, centred in it. The
// cell is as wide as the widest pair, so the button never changes width and
// nothing beside it moves, and a narrower face sits in the middle rather than
// against one edge. Hidden faces are `invisible`: they keep their box (that
// is what holds the width) and leave the accessibility tree, so a screen
// reader reads only the face that shows.
//
// Only the INCOMING face fades. The outgoing one drops out at once, so two
// faces are never painted together, which is the crossfade ThemeToggle
// rejects. A transition and not a keyframe, because a face can be swapped
// repeatedly and a keyframe enter restarts from zero each time. Opacity only:
// every Tabler glyph already carries `scale` for the kit's optical correction.
//
// Each pair takes Button's own icon-to-label gap for its size, from the table
// Button itself reads (`buttonGap`). It cannot inherit it: Button wraps its
// children in a `display: contents` span, and `gap: inherit` reads that span's
// gap, which is none, so the icon landed flush against the label.
//
// The icon-side padding trim works the same way. Button only applies it to
// `iconStart`, which SwapButton never passes, so it applies
// `buttonIconSidePadding` itself when EVERY face leads with an icon. A face
// set that mixes icon and text-only faces keeps symmetric padding, since there
// is no single icon side to balance.
const faceBase = "col-start-1 row-start-1 inline-flex items-center justify-center"
const faceShown = "opacity-100 transition-[opacity] duration-small ease-small"
const faceHidden = "invisible opacity-0"
/**
* A button that changes what it says with a state, at one fixed width: the
* new icon and label fade in where the old ones were. Everything else is a
* normal `Button`, and `onClick` is yours, so the state can mean anything.
*/
// @use-when a button whose icon and label change with a state (Use, then Copy, then Copied) without changing width. For a plain copy button, `CopyLabelButton` is this already wired to the clipboard.
function SwapButton({ states, state, size, className, ...props }: SwapButtonProps) {
const sizeKey = size ?? "default"
const gap = sizeKey in buttonGap ? buttonGap[sizeKey as keyof typeof buttonGap] : buttonGap.xs
const faces = Object.values(states)
const iconPad = faces.length > 0 && faces.every((f) => f.icon) ? buttonIconSidePadding[sizeKey]?.start : undefined
return (
<Button data-slot="swap-button" data-state={state} size={size} className={cn(iconPad, className)} {...props}>
<span className="grid">
{Object.entries(states).map(([key, face]) => (
<span key={key} className={cn(faceBase, gap, key === state ? faceShown : faceHidden)}>
{renderIconProp(face.icon, { "aria-hidden": true })}
{face.label}
</span>
))}
</span>
</Button>
)
}
export { SwapButton }
export type { SwapButtonProps, SwapButtonState }