Installation
$ pnpm dlx shadcn@latest add https://lab.pratikthapw.dev/r/button.jsonThe composed API (loading, swap, leftIcon, rightIcon, and children with data-icon) is complete as installed; no extra pieces are required.
Usage
import { Button } from "@/components/ui/button";
export function Actions() {
return (
<div className="flex flex-wrap gap-3">
<Button>Save changes</Button>
<Button variant="secondary">Preview</Button>
<Button variant="outline">Cancel</Button>
<Button variant="destructive">Delete</Button>
</div>
);
}Examples
Variants
Use semantic variants to communicate action hierarchy. Avoid choosing a variant only for its color.
import { Button } from "@/components/ui/button";
const variants = [
"default",
"secondary",
"outline",
"ghost",
"destructive",
"link",
] as const;
export const ButtonVariantsExample = () => (
<div className="border-border bg-card flex flex-wrap items-center gap-3 rounded-xl border p-6">
{variants.map((variant) => (
<Button key={variant} variant={variant}>
{variant[0]?.toUpperCase()}
{variant.slice(1)}
</Button>
))}
</div>
);Left icon
leftIcon accepts any node and renders it in a dedicated data-icon="inline-start" slot, so spacing stays predictable. When loading is true, the spinner replaces the left icon in place, keeping width stable.
import { ArrowRightIcon, CheckIcon } from "@/components/ui/icons";
import { RenderIcon } from "@/components/ui/render-icon";
import { Button } from "@/components/ui/button";
export const ButtonLeftIconExample = () => (
<div className="border-border bg-card flex flex-wrap items-center gap-3 rounded-xl border p-6">
<Button leftIcon={<RenderIcon icon={ArrowRightIcon} />}>Continue</Button>
<Button leftIcon={<RenderIcon icon={CheckIcon} />} variant="outline">
Approve
</Button>
<Button
leftIcon={<RenderIcon icon={ArrowRightIcon} />}
loading
variant="secondary"
>
Saving changes
</Button>
</div>
);<Button leftIcon={<ArrowRightIcon />}>Continue</Button>
<Button leftIcon={<CheckIcon />} variant="outline">Approve</Button>Right icon
rightIcon mirrors the left slot at data-icon="inline-end". Use it for onward actions and dismissive affordances; the right slot never swaps for the loading spinner, so trailing icons stay visible while loading.
import { ArrowRightIcon, BellIcon } from "@/components/ui/icons";
import { RenderIcon } from "@/components/ui/render-icon";
import { Button } from "@/components/ui/button";
export const ButtonRightIconExample = () => (
<div className="border-border bg-card flex flex-wrap items-center gap-3 rounded-xl border p-6">
<Button rightIcon={<RenderIcon icon={ArrowRightIcon} />}>Next step</Button>
<Button rightIcon={<RenderIcon icon={BellIcon} />} variant="outline">
Notify me
</Button>
<Button rightIcon={<RenderIcon icon={ArrowRightIcon} />} variant="ghost">
Skip
</Button>
</div>
);<Button rightIcon={<ArrowRightIcon />}>Next step</Button>
<Button rightIcon={<BellIcon />} variant="outline">Notify me</Button>Loading
loading disables the button and shows a spinner. When leftIcon is set, the spinner replaces it in place; otherwise it renders at inline-start.
"use client";
import { Button } from "@/components/ui/button";
export const ButtonLoadingExample = () => (
<div className="border-border bg-card flex flex-wrap items-center gap-3 rounded-xl border p-6">
<Button loading>Saving changes</Button>
<Button loading variant="outline">
Cancel
</Button>
<Button aria-label="Loading" loading size="icon" variant="secondary" />
</div>
);<Button loading>Saving changes</Button>
<Button leftIcon={<ArrowRightIcon />} loading>Continue</Button>
<Button aria-label="Loading" loading size="icon" variant="secondary" />Swap
With no icons set, pass swap to stack the label and spinner in the same cell: the label becomes invisible and the spinner takes its place, so the button width never shifts mid-flight.
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
export const ButtonSwapExample = () => {
const [loading, setLoading] = useState(true);
return (
<div className="border-border bg-card flex flex-wrap items-center gap-3 rounded-xl border p-6">
<Button loading={loading} swap>
Save changes
</Button>
<Button
onClick={() => setLoading((value) => !value)}
size="sm"
variant="outline"
>
{loading ? "Set idle" : "Set loading"}
</Button>
</div>
);
};<Button loading={isPending} swap>
Save changes
</Button>swap only applies when neither leftIcon nor rightIcon is set; with icons present the in-place replacement already keeps width stable.
You can still place icons as ordinary children and tag them with data-icon="inline-start" or data-icon="inline-end"; the props above are sugar over that same contract.
API
Props
| Prop | Type | Default |
|---|---|---|
variant | "default" | "secondary" | "outline" | "ghost" | "destructive" | "link" | "default" |
size | "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | "default" |
loading | boolean | false |
leftIcon | React.ReactNode | None |
rightIcon | React.ReactNode | None |
swap | boolean | false |
When loading is true, the button is disabled and a private spinner renders: in place of leftIcon when set, inside the swap cell when swap is on, or at inline-start otherwise. Icon-only buttons still need an accessible name via aria-label or visually hidden text.
Polymorphism
Button uses Base UI's render prop and nativeButton. Pass render={<a />} only when the rendered element is not a native button, and set nativeButton={false} accordingly.
For links that look like buttons, prefer the exported buttonVariants helper with a plain <a> instead of rendering a link through the button. This keeps the semantic link role intact.
import { buttonVariants } from "@/components/ui/button";
<a className={buttonVariants({ variant: "outline" })} href="/pricing">
Pricing
</a>;