Shadcn Button for React and Tailwind
Triggers an action or navigates when used as a link.
Installation
bunx --bun shadcn@latest add https://kit.dev/r/button.jsonpnpm dlx shadcn@latest add https://kit.dev/r/button.jsonnpx shadcn@latest add https://kit.dev/r/button.jsonyarn shadcn@latest add https://kit.dev/r/button.json<Step>This component depends on Spinner. Install it first if you haven't already.</Step>
Import the following variables into your CSS file
@theme inline {
--color-destructive-foreground: var(--destructive-foreground);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
}
:root {
--destructive-foreground: var(--color-red-700);
--info: var(--color-blue-500);
--info-foreground: var(--color-blue-700);
--success: var(--color-emerald-500);
--success-foreground: var(--color-emerald-700);
--warning: var(--color-amber-500);
--warning-foreground: var(--color-amber-700);
}
.dark {
--destructive-foreground: var(--color-red-400);
--info: var(--color-blue-500);
--info-foreground: var(--color-blue-400);
--success: var(--color-emerald-500);
--success-foreground: var(--color-emerald-400);
--warning: var(--color-amber-500);
--warning-foreground: var(--color-amber-400);
}Install the following dependencies:
bun add @ark-ui/react tailwind-variantspnpm add @ark-ui/react tailwind-variantsnpm install @ark-ui/react tailwind-variantsyarn add @ark-ui/react tailwind-variantsCopy and paste the following code into your project.
import { ark } from "@ark-ui/react/factory";
import type React from "react";
import { tv, type VariantProps } from "tailwind-variants";
import { cn } from "@/lib/utils";
import { Spinner } from "@/components/ui/spinner";
export const buttonVariants = tv({
base: [
"relative",
"inline-flex shrink-0 items-center justify-center gap-2",
"whitespace-nowrap font-medium text-sm",
"rounded-lg",
"transition-[color,background-color,border-color,box-shadow,opacity,transform]",
"outline-none focus-visible:ring-[3px] focus-visible:ring-ring/32",
"disabled:pointer-events-none disabled:opacity-64",
"data-disabled:pointer-events-none data-disabled:opacity-64",
"aria-disabled:pointer-events-none aria-disabled:opacity-64",
"data-[state=loading]:pointer-events-none",
"aria-invalid:border-destructive aria-invalid:ring-destructive/24",
"[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"motion-reduce:transition-none!",
],
variants: {
variant: {
default: [
"bg-primary",
"border border-transparent shadow-primary/24 shadow-sm",
"text-primary-foreground",
"hover:bg-primary/90",
"focus-visible:border-background",
],
outline: [
"bg-transparent",
"text-foreground",
"border border-input shadow-sm/5",
"hover:bg-accent hover:text-accent-foreground",
"dark:bg-input/32 dark:hover:bg-input/64",
"focus-visible:border-primary",
],
destructive: [
"bg-destructive",
"text-white",
"border border-transparent shadow-destructive/24 shadow-sm",
"hover:bg-destructive/90",
"focus-visible:border-background focus-visible:ring-destructive-foreground/32",
],
secondary: [
"bg-secondary",
"text-secondary-foreground",
"border border-transparent",
"focus-visible:border-primary",
"hover:bg-secondary/80",
],
ghost: [
"hover:bg-accent hover:text-accent-foreground",
"border border-transparent",
"focus-visible:border-primary",
],
link: [
"text-primary",
"underline-offset-4",
"border border-transparent",
"hover:underline",
"focus-visible:border-primary",
],
},
size: {
xs: [
"h-6",
"gap-1.5",
"px-2",
"text-xs",
"rounded-sm",
"[&_svg:not([class*='size-'])]:size-2.5",
],
sm: [
"h-7",
"px-2.5",
"gap-1.5",
"[&_svg:not([class*='size-'])]:size-3.5",
],
md: ["h-8", "px-3", "py-2"],
lg: ["h-9", "px-3.5"],
xl: ["h-10", "text-base", "px-4"],
"icon-xs": "size-6 rounded-sm",
"icon-sm": "size-7",
"icon-md": "size-8",
"icon-lg": "size-9",
"icon-xl": "size-10 [&_svg:not([class*='size-'])]:size-5",
},
clickEffect: {
true: "active:not-aria-[haspopup]:scale-[0.98]",
},
pill: {
true: [
"rounded-full",
"has-[>svg]:data-[size=xs]:pe-3",
"has-[>svg]:data-[size=sm]:pe-3.5",
"has-[>svg]:data-[size=md]:pe-4",
"has-[>svg]:data-[size=lg]:pe-4.5",
"has-[>svg]:data-[size=xl]:pe-5",
],
},
},
defaultVariants: {
variant: "default",
size: "md",
clickEffect: true,
pill: false,
},
});
export interface ButtonProps
extends React.ComponentProps<typeof ark.button>,
VariantProps<typeof buttonVariants> {
/**
* Apply a click effect to the button
*
* @default true
*/
clickEffect?: boolean;
/**
* Show a loading indicator
*
* @default false
*/
isLoading?: boolean;
}
export const Button = (props: ButtonProps) => {
const {
variant = "default",
size = "md",
clickEffect = true,
pill = false,
isLoading = false,
asChild,
type = "button",
disabled = false,
className,
children,
...rest
} = props;
return (
<ark.button
asChild={asChild}
className={cn(
buttonVariants({ clickEffect, pill, size, variant }),
className
)}
data-pill={pill || undefined}
data-size={size}
data-slot="button"
data-state={isLoading ? "loading" : "idle"}
data-variant={variant}
disabled={disabled || isLoading}
type={asChild ? undefined : type}
{...rest}
aria-busy={isLoading || undefined}
>
{asChild || !isLoading ? (
children
) : (
<>
<span aria-hidden className="invisible">
{children}
</span>
<span className="sr-only">{children}</span>
<span className="absolute inset-0 flex items-center justify-center">
<Spinner aria-hidden />
</span>
</>
)}
</ark.button>
);
};Update the import paths to match your project setup.
Anatomy
A single control. Icons are children. isLoading replaces the visible label with a spinner and keeps the text for assistive tech.
Button
└── content (text, icon, …)Usage
import { Button } from "@/components/ui/button";<Button>Save</Button>Button defaults to type="button". In a form, set type="submit" (or reset) explicitly.
Link
asChild merges button styles onto a single child. Use it for Link or a. Do not combine asChild with isLoading.
States
Disabled
Loading
isLoading sets data-state="loading", aria-busy, and disabled, and shows a Spinner without changing the button’s width.
Variants
Default
Outline
Secondary
Ghost
Link
Text-styled. For real navigation, prefer asChild with Link over variant="link" on a <button>.
Destructive
Sizes
| Size | Height | Notes |
|---|---|---|
xs | h-6 | text-xs, rounded-sm |
sm | h-7 | |
md | h-8 | Default |
lg | h-9 | |
xl | h-10 | text-base |
icon-xs | size-6 | Square, rounded-sm |
icon-sm | size-7 | Square |
icon-md | size-8 | Square |
icon-lg | size-9 | Square |
icon-xl | size-10 | Square, icon size-5 |
There is no size="icon" or size="default". Use icon-md and md.
Extra small
Small
Medium
Large
Extra large
Icon sizes
Icon-only buttons need aria-label.
Pill
pill uses rounded-full. With an SVG child, end padding increases so the icon is not clipped.
Click effect
The default press scale is off when the button has aria-haspopup (menus, selects). Set clickEffect={false} to disable it everywhere.
Examples
Icon only
With icon
Put the icon before the label (or after, for an external-link cue). Decorative icons should be aria-hidden="true".
Touch hitbox
Expand the tap target with Hitbox without changing layout.
Custom color
Override background, text, and shadow with className. Prefer semantic tokens when a variant already exists.
Guides
asChild
asChild comes from Ark’s factory. The child must be a single element:
<Button asChild variant="outline">
<Link href="/login">Login</Link>
</Button>type="button" is omitted when asChild is set so it is not copied onto an <a>.
Forms
The default type is "button" so a Button inside a <form> does not submit. Use type="submit" for the submitting action.
Button vs Button Group vs Link
| Button | Button Group | variant="link" | |
|---|---|---|---|
| Role | One action | Visually connected actions | Looks like a text link |
| Navigation | asChild + Link | — | Still a button unless asChild |
Using buttonVariants
import { buttonVariants } from "@/components/ui/button";
<a className={buttonVariants({ size: "sm", variant: "outline" })} href="/docs">
Docs
</a>API Reference
shadcn.io Button is a styled button (Ark factory), not a Zag machine. There is no context hook or root provider.
asChild merges props onto a single child element.
Button
Root. Renders a button.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "outline" | "secondary" | "ghost" | "link" | "destructive" | "default" | Color treatment. |
size | "xs" | "sm" | "md" | "lg" | "xl" | "icon-xs" | "icon-sm" | "icon-md" | "icon-lg" | "icon-xl" | "md" | Height, padding, and icon box. See Sizes. |
pill | boolean | false | Fully rounded ends. Extra end padding when an SVG child is present. |
clickEffect | boolean | true | Scale down on press, except when aria-haspopup is set. |
isLoading | boolean | false | Disable the control, set aria-busy, and show a spinner. Ignored with asChild. |
type | "button" | "submit" | "reset" | "button" | Native type. Omitted when asChild. |
disabled | boolean | false | Native disabled. Also set when isLoading. |
asChild | boolean | false | Render the child element instead of a button. |
className | string | - | Class names on the root. |
Native button attributes (name, value, form, aria-label, aria-haspopup, …) pass through.
| Attribute | Description |
|---|---|
data-slot | button |
data-variant | "default", "outline", "secondary", "ghost", "link", or "destructive" |
data-size | The size token |
data-state | "loading" or "idle" |
data-pill | "true" when pill is set |
aria-busy | Present when isLoading |
disabled | Present when disabled or isLoading |
buttonVariants
Exported tv() recipe. Same variant, size, pill, and clickEffect keys as Button.
buttonVariants({ size: "sm", variant: "outline" });ButtonProps
Props of Button: button attributes plus VariantProps<typeof buttonVariants>, clickEffect, and isLoading.
Accessibility
Follows the Button WAI-ARIA pattern. The accessible name is the visible text, or aria-label when the control is icon-only.
- Decorative icons (the label already explains the action):
aria-hidden="true". - Icon-only:
aria-labelonButton. - Loading: the original children stay in
.sr-only; the spinner isaria-hidden. disabledandisLoadingtake the control out of the tab order.
Keyboard support
| Key | Description |
|---|---|
Tab | Move to the button. |
Shift + Tab | Move to the previous control. |
Enter | Activate. |
Space | Activate. |