Action swap
Swap an action label while preserving its place and intent.
pnpm dlx shadcn@latest add @uiarc/action-swapLive · keyboard ready
- One control that cycles through related states, like Follow and Following or Send and Sent.
- Toggles that run async work on press and should show a working phase and a failure message in place.
- Short confirmations that should revert on their own after a moment, via holdFor.
- Use action-button for a single save or publish that ends in a success tick.
- Use button when the label never changes state.
- Use theme-switch for a light and dark toggle, since it reports the theme change for a page transition.
Installation
Add Action swap with the shadcn CLI, or copy the source by hand.
pnpm dlx shadcn@latest add @uiarc/action-swapAdds the component and its local dependencies, and installs motion, lucide-react. First time? Add the @uiarc registry to components.json, or use the full URL:
example.tsx
import { ActionSwap } from "@/components/arc/action-swap/action-swap";import { Send, Check } from "lucide-react"; export function SendInvite() { return ( <ActionSwap states={[ { id: "idle", label: "Send invite", icon: <Send size={16} /> }, { id: "sent", label: "Sent", icon: <Check size={16} />, tone: "success", pendingLabel: "Sending", holdFor: 2000 }, ]} onAction={next => (next.id === "sent" ? sendInvite() : undefined)} /> );}Follow toggle
example.tsx
<ActionSwap states={[ { id: "follow", label: "Follow", tone: "accent" }, { id: "following", label: "Following", tone: "quiet", pendingLabel: "Following" }, ]} onAction={next => setFollowing(next.id === "following")}/>API reference
2 parts. The first is the root.
ActionSwap
A compact pill that steps through a list of states on each press, morphing icon, tone, and letters together.
PropTypeDefaultDescription
statesRequiredActionSwapState[]–Ordered states. Each press moves to the next and wraps around. Needs at least two to be enabled.valuestring–Controlled state id.defaultValuestring–Initial state id when uncontrolled. Defaults to the first state.onValueChange(value: string, state: ActionSwapState) => void–Called when the control commits to a new state.onAction(next: ActionSwapState, current: ActionSwapState) => void | PromiseLike<unknown>–Runs on press. Return a promise to show a working phase; a rejection shows failureLabel and stays put.failureLabelstring"Try again"Label shown for 1.8s after onAction rejects or throws.size"sm" | "md" | "lg""md"Height and padding....propsButtonHTMLAttributes<HTMLButtonElement>–Forwarded to the button, except children, onClick, onChange, and value.ActionSwapState
One state in the states array.
PropTypeDefaultDescription
idRequiredstring–Stable id used by value and onValueChange.labelRequiredstring–Visible label and accessible name.iconReactNode–Leading icon, sized at 16px.tone"neutral" | "accent" | "success" | "warning" | "quiet""neutral"Fill color. Success draws its icon in with a stroke wipe.pendingLabelstring–Shown while onAction runs on the way into this state. Without it the pill folds into a round spinner.holdFornumber–Milliseconds to hold this state before advancing on its own, for brief confirmations.- EnterorSpace
- Advances to the next state.
- Native button whose aria-label always matches the visible label, since the letters themselves are aria-hidden.
- The working phase sets aria-busy and aria-disabled, so focus stays on the button.
- A polite role="status" region announces each new label, pending label, and failure.
- Width, padding, tone, and icon move on one morph spring; only the changed run of letters rises in from a soft blur.
- A working state without pendingLabel folds the pill into a circle; the working shape shows for at least 450ms.
- Reduced motion jumps width and fold and swaps icons and letters with an instant fade.
- Width follows the current label, so keep labels similar in length to avoid shifting neighbours on narrow rows.
- Hover styles apply only on hover-capable fine pointers; on touch the press scale is the only feedback.
- Each letter is its own motion span with layout position animation; keep labels short and avoid many swaps on one screen.
- A ResizeObserver tracks the label width for the morph spring.
Notes for AI
Give your coding assistant the Markdown reference instead of screenshots.
- Pick this for one control that cycles through related states (Follow → Following, Send → Sent → Send). Use action-button for a single save with a success tick.
- Keep states few and labels short; the morph reads best between similar lengths.
- Do the work in onAction and return its promise; control value only when the state lives elsewhere.
The full library index for assistants is at /llms.txt.