Button
A button with a full variant × tone × size matrix: 5 × 6 × 10 = 300 combinations, plus loading and composition states.
import { ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
export default function ButtonDemo() {
return (
<div className="flex flex-wrap items-center gap-3">
<Button tone="brand">
Get started <ArrowRight />
</Button>
<Button variant="outline">View docs</Button>
<Button variant="soft" tone="danger">
Delete
</Button>
<Button tone="brand" isLoading>
Deploying
</Button>
</div>
)
}Installation
Run the following command
npx shadcn@latest add @ai2/buttonDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install class-variance-authority@^0.7.1 radix-ui@^1.6.1 lucide-react@^1.23.0Add the cn util
lib/utils.tsimport { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/* Adds a source-attribution ref param to a URL (the inspiration exports mark their
outbound links with an ai2.design attribution). An invalid URL is returned as is.
This file is SHOWN TO THE CONSUMER: the docs component pages render the source of
`cn` in a code block, so a Turkish comment here would reach every one of those
pages. Keep it English. */
export function withRef(url: string, ref = "ai2.design"): string {
try {
const u = new URL(url)
u.searchParams.set("ref", ref)
return u.toString()
} catch {
return url
}
}Copy the source code
components/ui/button.tsx"use client"
import type * as React from "react"
import { Slot } from "@/components/ui/primitives"
import { cva, type VariantProps } from "class-variance-authority"
import { Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center whitespace-nowrap font-medium outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-danger aria-invalid:ring-danger/20 dark:aria-invalid:ring-danger/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_i]:pointer-events-none [&_i]:shrink-0 [&_i]:text-base [&_i]:leading-none",
{
variants: {
variant: {
solid: "",
soft: "",
outline: "border bg-transparent",
ghost: "bg-transparent",
link: "bg-transparent underline-offset-4 hover:underline",
},
tone: {
neutral: "",
brand: "",
success: "",
warning: "",
danger: "",
info: "",
},
size: {
xs: "h-7 gap-1 rounded-md px-2.5 text-xs",
sm: "h-8 gap-1.5 rounded-md px-3 text-sm",
md: "h-9 gap-2 rounded-lg px-4 text-sm",
lg: "h-10 gap-2 rounded-lg px-5 text-sm",
xl: "h-12 gap-2.5 rounded-xl px-6 text-base",
"icon-xs": "size-7 rounded-md text-xs",
"icon-sm": "size-8 rounded-md text-sm",
icon: "size-9 rounded-lg text-sm",
"icon-lg": "size-10 rounded-lg text-sm",
"icon-xl": "size-12 rounded-xl text-base",
},
},
compoundVariants: [
{ variant: "solid", tone: "neutral", class: "bg-primary text-primary-foreground hover:bg-primary/90" },
{ variant: "solid", tone: "brand", class: "bg-brand text-brand-foreground hover:bg-brand/90" },
{ variant: "solid", tone: "success", class: "bg-success text-success-foreground hover:bg-success/90" },
{ variant: "solid", tone: "warning", class: "bg-warning text-warning-foreground hover:bg-warning/90" },
{ variant: "solid", tone: "danger", class: "bg-danger text-danger-foreground hover:bg-danger/90" },
{ variant: "solid", tone: "info", class: "bg-info text-info-foreground hover:bg-info/90" },
{ variant: "soft", tone: "neutral", class: "bg-secondary text-secondary-foreground hover:bg-secondary/80" },
{ variant: "soft", tone: "brand", class: "bg-brand-soft text-brand-soft-foreground hover:bg-brand-soft/80" },
{ variant: "soft", tone: "success", class: "bg-success-soft text-success-soft-foreground hover:bg-success-soft/80" },
{ variant: "soft", tone: "warning", class: "bg-warning-soft text-warning-soft-foreground hover:bg-warning-soft/80" },
{ variant: "soft", tone: "danger", class: "bg-danger-soft text-danger-soft-foreground hover:bg-danger-soft/80" },
{ variant: "soft", tone: "info", class: "bg-info-soft text-info-soft-foreground hover:bg-info-soft/80" },
{ variant: "outline", tone: "neutral", class: "border-border text-foreground hover:bg-accent hover:text-accent-foreground" },
{ variant: "outline", tone: "brand", class: "border-brand/40 text-brand hover:bg-brand-soft" },
{ variant: "outline", tone: "success", class: "border-success/40 text-success hover:bg-success-soft" },
{ variant: "outline", tone: "warning", class: "border-warning/50 text-warning-soft-foreground hover:bg-warning-soft" },
{ variant: "outline", tone: "danger", class: "border-danger/40 text-danger hover:bg-danger-soft" },
{ variant: "outline", tone: "info", class: "border-info/40 text-info hover:bg-info-soft" },
{ variant: "ghost", tone: "neutral", class: "text-foreground hover:bg-accent hover:text-accent-foreground" },
{ variant: "ghost", tone: "brand", class: "text-brand hover:bg-brand-soft" },
{ variant: "ghost", tone: "success", class: "text-success hover:bg-success-soft" },
{ variant: "ghost", tone: "warning", class: "text-warning-soft-foreground hover:bg-warning-soft" },
{ variant: "ghost", tone: "danger", class: "text-danger hover:bg-danger-soft" },
{ variant: "ghost", tone: "info", class: "text-info hover:bg-info-soft" },
{ variant: "link", tone: "neutral", class: "text-foreground" },
{ variant: "link", tone: "brand", class: "text-brand" },
{ variant: "link", tone: "success", class: "text-success" },
{ variant: "link", tone: "warning", class: "text-warning-soft-foreground" },
{ variant: "link", tone: "danger", class: "text-danger" },
{ variant: "link", tone: "info", class: "text-info" },
],
defaultVariants: {
variant: "solid",
tone: "neutral",
size: "md",
},
}
)
interface ButtonProps
extends React.ComponentProps<"button">,
VariantProps<typeof buttonVariants> {
asChild?: boolean
isLoading?: boolean
}
function Button({
className,
variant,
tone,
size,
asChild = false,
isLoading = false,
disabled,
children,
...props
}: ButtonProps) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant ?? "solid"}
data-tone={tone ?? "neutral"}
className={cn(buttonVariants({ variant, tone, size, className }))}
disabled={disabled || isLoading}
aria-busy={isLoading || undefined}
{...props}
>
{asChild ? (
children
) : (
<>
{isLoading && <Loader2 aria-hidden="true" className="animate-spin" />}
{children}
</>
)}
</Comp>
)
}
export { Button, buttonVariants, type ButtonProps }Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Usage
import { Button } from "@/components/ui/button"
<Button variant="soft" tone="danger" size="lg">
Delete project
</Button>All three axes are optional. Omit them and you get the neutral solid medium button. Any combination of the axes is valid.
Examples
Variants
Tones
Tones combine with every variant: variant="soft" tone="danger" is a prop pair, not custom CSS.
Sizes
Icon buttons
Five square sizes (size-7 through size-12) share the height, radius and text scale of xs through xl, so an icon button sits flush next to an md one. Icon-only buttons have no visible label, so aria-label is required. See the icons guide for the full placement rules.
Loading, disabled and invalid
aria-invalid switches the border and focus ring to the danger tokens, the same invalid treatment every ai2 form control uses.
As child
With asChild, the button must have exactly one element child; extra nodes (including isLoading's spinner) are skipped by design.
Props
Button also accepts every native <button> prop.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "solid" | "soft" | "outline" | "ghost" | "link" | "solid" | Visual style of the button. |
tone | "neutral" | "brand" | "success" | "warning" | "danger" | "info" | "neutral" | Semantic color channel, mapped to theme tokens. |
size | "xs" | "sm" | "md" | "lg" | "xl" | "icon-xs" | "icon-sm" | "icon" | "icon-lg" | "icon-xl" | "md" | Height, padding, radius and typography scale. The icon-* values are square (size-7 through size-12) for icon-only buttons. |
isLoading | boolean | false | Shows a spinner and disables the button. Ignored when asChild is set. |
asChild | boolean | false | Merges props onto the child element (e.g. a Link) instead of rendering a native button. |
ai2 Button: 300 variant combinations from three typed props
The ai2 Button is a shadcn-compatible react button component styled with Tailwind CSS v4. Instead of a handful of preset looks, it exposes a full variant x tone x size matrix: 5 variants, 6 tones and 10 sizes (5 text sizes plus 5 square icon-only sizes), which multiplies out to 300 valid combinations. A destructive soft action, a brand outline CTA or an extra-small ghost icon button are all prop pairs, not custom CSS.
It ships through the shadcn registry format, so you install it with one CLI command, an MCP agent, or a copy-paste, and the TypeScript source lands in your own project. You own the file; there is no runtime dependency on ai2 itself. The live example above is the exact component you get.
What is the ai2 Button?
A single Button component plus an exported buttonVariants cva function that other components (like AlertDialog) reuse for button-shaped elements. Three typed axes drive the look: variant (solid, soft, outline, ghost, link), tone (neutral, brand, success, warning, danger, info) and size (xs through xl, plus the square icon-xs through icon-xl for icon-only buttons).
On top of the matrix it adds two behavior props: isLoading renders a spinning loader and disables the button, and asChild merges the button styling onto a child element such as a framework Link. Every tone color resolves to the ai2 token file, so all 300 combinations follow your theme in light and dark mode.
Why use it
- One matrix instead of variant sprawl: Classic shadcn buttons mix intent and style into one prop (default, destructive, secondary). ai2 separates variant, tone and size, so "soft danger" or "outline brand" needs zero custom classes.
- Loading state built in: isLoading shows a spinner, keeps the label visible and disables the button in one prop, so you never wire aria and pointer-events by hand for async actions.
- Composes as a link: asChild renders your own element (a Next.js Link, a plain anchor) with full button styling, keeping navigation semantic instead of nesting a button in an anchor.
- Accessible focus and invalid states: Every combination keeps a visible focus ring (focus-visible:ring) and styles aria-invalid, and disabled buttons drop pointer events and dim consistently.
- Agent-readable metadata: The registry item describes the 300-combination matrix in plain words, so an MCP agent can pick the right variant, tone and size without guessing.
Features
- 5 variants x 6 tones x 10 sizes: 300 combinations generated by cva compound variants, all resolved from theme tokens rather than hardcoded colors. Five sizes are square icon-only boxes (icon-xs through icon-xl).
- isLoading spinner: A lucide loader animates in front of the label and the button disables itself while the async work runs.
- asChild composition: Built on the radix Slot primitive; the loading spinner injection is bypassed automatically so Slot always receives exactly one child.
- Automatic icon sizing: svg children are sized to size-4 by default and set to shrink-0, so icons line up without per-icon classes.
- Data attributes for styling: The root exposes data-slot, data-variant and data-tone, so you can restyle specific combinations from CSS without forking the file.
- buttonVariants export: The cva function is exported separately, so you can apply exact button styling to any element or reuse it in your own components.
Production tips
- Reserve solid danger for the primary destructive action: In dialogs and settings screens, use variant="solid" tone="danger" once, and soft or outline danger for secondary destructive options, so severity stays scannable.
- Do not combine asChild with isLoading: When asChild is set the spinner injection is skipped by design, because radix Slot accepts exactly one element child. Handle loading state inside the child instead.
- Match size to density: md is the default for forms and dialogs, sm and xs fit table rows and toolbars, xl fits marketing CTAs. Mixing sizes inside one row usually reads as a bug.
- Prefer tone over custom classes: If you find yourself overriding background colors with className, you probably want a different tone. The six tones map to the semantic tokens your whole theme uses.
- Keep labels verbs: Buttons read best as actions: "Delete project", "Save draft". Ghost and link variants especially need clear labels because they carry less visual weight.
Works with the rest of ai2
The button is the workhorse of the registry. It renders the actions in ai2 Dialog and ai2 Alert Dialog footers (Alert Dialog reuses buttonVariants directly for its action and cancel buttons), and it triggers ai2 Dropdown Menu and ai2 Popover overlays via asChild.
In forms, pair it with ai2 Field rows for submit and cancel actions, and use isLoading while the request is in flight. Everything shares one token source, so a brand button next to a danger badge stays visually consistent in both modes.