A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
What changed
- 1.0.4 Oct 12, 2026Every size honours the platform feel's smallest touch target (--bm-target-min): 44 px under the iOS feel, no change otherwise.
- 1.0.3 Oct 12, 2026A loading button keeps its label in the accessibility tree (opacity instead of visibility), so a busy button still has a name.
- 1.0.2 Oct 11, 2026Control heights, menu and sidebar row sizes, press dimming and frosted surfaces follow the platform feel tokens (--bm-control-h, --bm-menu-*, --bm-sidebar-*, --bm-material-*). Unchanged without a feel.
- 1.0.1 Oct 11, 2026Hover lift and press depth follow the theme's --bm-lift and --bm-hover tokens. Unchanged by default.
- 1.0.0 Sep 28, 2026First release.
import { ArrowRight } from "lucide-react"
import { Button } from "@/components/ballmac/button"
export default function ButtonDemo() {
return (
<Button>
Get started <ArrowRight className="rtl:rotate-180"/>
</Button>
)
}Installation
The default, and what a Radix-style project installs.
$ pnpm dlx shadcn@latest add @ballmac/buttonInstall the dependencies.
$ pnpm add radix-ui class-variance-authorityCopy the source into your project.
components/ballmac/button.tsx// Ballmac UI: Button. https://ui.ballmac.com/components/button // Based on shadcn/ui's Button (MIT, Copyright (c) 2023 shadcn), restyled and extended. import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { Slot } from "radix-ui" import { cn } from "@/lib/utils" const buttonVariants = cva( "relative inline-flex min-h-[var(--bm-target-min,0px)] min-w-[var(--bm-target-min,0px)] shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-[color,background-color,border-color,box-shadow,transform] duration-150 select-none active:opacity-[var(--bm-press-dim,1)] active:translate-y-[calc(1px*var(--bm-hover,1))] disabled:pointer-events-none disabled:opacity-50 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background aria-invalid:ring-destructive/30 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground shadow-[inset_0_1px_0_0_rgb(255_255_255/0.12),0_1px_2px_0_rgb(0_0_0/0.12)] hover:bg-primary/90", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", outline: "border border-border bg-background shadow-xs hover:border-foreground/20 hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-foreground underline-offset-4 hover:underline", destructive: "bg-destructive text-white hover:bg-destructive/90 dark:bg-destructive/60 dark:hover:bg-destructive/50 focus-visible:ring-destructive/40", }, size: { sm: "h-[var(--bm-control-h-sm,calc(var(--spacing)*8))] gap-1.5 px-3 text-[13px]", default: "h-[var(--bm-control-h,calc(var(--spacing)*9))] px-4", lg: "h-[var(--bm-control-h-lg,calc(var(--spacing)*11))] px-6 text-[15px]", icon: "size-[var(--bm-control-h,calc(var(--spacing)*9))]", "icon-sm": "size-[var(--bm-control-h-sm,calc(var(--spacing)*8))]", }, shape: { default: "", pill: "rounded-full", }, }, defaultVariants: { variant: "default", size: "default", shape: "default" }, } ) type ButtonProps = React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { /** Render the child element (for example a link) with button styles. */ asChild?: boolean /** Shows a spinner, sets aria-busy and blocks clicks. */ loading?: boolean } function Button({ className, variant, size, shape, asChild = false, loading = false, disabled, children, ...props }: ButtonProps) { const Comp = asChild ? Slot.Root : "button" return ( <Comp data-slot="button" data-loading={loading || undefined} aria-busy={loading || undefined} disabled={asChild ? undefined : disabled || loading} className={cn(buttonVariants({ variant, size, shape }), className)} {...props} > {loading && !asChild ? ( <> <span className="absolute inset-0 flex items-center justify-center" aria-hidden="true"> <svg className="size-4 animate-spin" viewBox="0 0 24 24" fill="none"> <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" /> <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" /> </svg> </span> {/* opacity, not visibility: the label stays in the accessibility tree, so a busy button still has a name */} <span className="inline-flex items-center gap-2 opacity-0">{children}</span> </> ) : ( children )} </Comp> ) } export { Button, buttonVariants, type ButtonProps }Update the import paths to match your project setup.
$ pnpm dlx shadcn@latest add https://ui.ballmac.com/r/base/button.jsonInstall the dependencies.
$ pnpm add class-variance-authority @base-ui/react@^1.9.0Add the Ballmac items it builds on (their Base UI versions where they have one).
$ pnpm dlx shadcn@latest add https://ui.ballmac.com/r/base/slot.jsonCopy the source into your project.
components/ballmac/button.tsx// Ballmac UI: Button. https://ui.ballmac.com/components/button // Based on shadcn/ui's Button (MIT, Copyright (c) 2023 shadcn), restyled and extended. import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { Slot } from "@/lib/ballmac/slot" import { cn } from "@/lib/utils" const buttonVariants = cva( "relative inline-flex min-h-[var(--bm-target-min,0px)] min-w-[var(--bm-target-min,0px)] shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-[color,background-color,border-color,box-shadow,transform] duration-150 select-none active:opacity-[var(--bm-press-dim,1)] active:translate-y-[calc(1px*var(--bm-hover,1))] disabled:pointer-events-none disabled:opacity-50 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background aria-invalid:ring-destructive/30 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground shadow-[inset_0_1px_0_0_rgb(255_255_255/0.12),0_1px_2px_0_rgb(0_0_0/0.12)] hover:bg-primary/90", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", outline: "border border-border bg-background shadow-xs hover:border-foreground/20 hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-foreground underline-offset-4 hover:underline", destructive: "bg-destructive text-white hover:bg-destructive/90 dark:bg-destructive/60 dark:hover:bg-destructive/50 focus-visible:ring-destructive/40", }, size: { sm: "h-[var(--bm-control-h-sm,calc(var(--spacing)*8))] gap-1.5 px-3 text-[13px]", default: "h-[var(--bm-control-h,calc(var(--spacing)*9))] px-4", lg: "h-[var(--bm-control-h-lg,calc(var(--spacing)*11))] px-6 text-[15px]", icon: "size-[var(--bm-control-h,calc(var(--spacing)*9))]", "icon-sm": "size-[var(--bm-control-h-sm,calc(var(--spacing)*8))]", }, shape: { default: "", pill: "rounded-full", }, }, defaultVariants: { variant: "default", size: "default", shape: "default" }, } ) type ButtonProps = React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { /** Render the child element (for example a link) with button styles. */ asChild?: boolean /** Shows a spinner, sets aria-busy and blocks clicks. */ loading?: boolean } function Button({ className, variant, size, shape, asChild = false, loading = false, disabled, children, ...props }: ButtonProps) { const Comp = asChild ? Slot : "button" return ( <Comp data-slot="button" data-loading={loading || undefined} aria-busy={loading || undefined} disabled={asChild ? undefined : disabled || loading} className={cn(buttonVariants({ variant, size, shape }), className)} {...props} > {loading && !asChild ? ( <> <span className="absolute inset-0 flex items-center justify-center" aria-hidden="true"> <svg className="size-4 animate-spin" viewBox="0 0 24 24" fill="none"> <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" /> <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" /> </svg> </span> {/* opacity, not visibility: the label stays in the accessibility tree, so a busy button still has a name */} <span className="inline-flex items-center gap-2 opacity-0">{children}</span> </> ) : ( children )} </Comp> ) } export { Button, buttonVariants, type ButtonProps }Update the import paths to match your project setup.
Usage
import { Button } from "@/components/ballmac/button"The full example is in the Code tab above.
Examples
Variants
import { Button } from "@/components/ballmac/button"
export default function ButtonVariants() {
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<Button>Default</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
<Button variant="destructive">Delete</Button>
<Button shape="pill">Pill</Button>
</div>
)
}Loading
"use client"
import * as React from "react"
import { Button } from "@/components/ballmac/button"
export default function ButtonLoading() {
const [loading, setLoading] = React.useState(false)
return (
<Button
loading={loading}
onClick={() => {
setLoading(true)
setTimeout(() => setLoading(false), 1600)
}}
>
Save changes
</Button>
)
}API reference
| Prop | Type | Default |
|---|---|---|
asChildRender the child element (for example a link) with button styles. | boolean | false |
loadingShows a spinner, sets aria-busy and blocks clicks. | boolean | false |
variant | "default" | "secondary" | "outline" | "ghost" | "link" | "destructive" | "default" |
size | "sm" | "default" | "lg" | "icon" | "icon-sm" | "default" |
shape | "default" | "pill" | "default" |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter / Space | Activates the button |
| Tab | Moves focus to the button |
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.4, across 3 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
No endless animation to begin with.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (0% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
9 tab stops, each showing a visible change when it takes focus. 2 documented keys.
Not applicable: Loading, empty and error
Does not show data or take submissions.
Passed: Automated tests
Covered by button.test.tsx.
For information: Footprint
1.6 KB of source (gzip) in 1 file. Installs radix-ui, class-variance-authority.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Use with AI
The base action control. Use variant for emphasis, size for density, and loading while an async action runs. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Button (@ballmac/button) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Primary and secondary actions
- Form submit buttons
- Links that should look like buttons (className={buttonVariants()} on an <a>)
Not for
- Navigation inside running text (use a link)
- Toggling state (use a switch or toggle)
Registry JSON: https://ui.ballmac.com/r/button.json
Credits
Based on shadcn/ui Button, adapted by Ballmac. Free to use in personal and commercial projects.
- Registry
- shadcn/utils
Pairs well with
Kbd
Keyboard key and shortcut display built on the semantic <kbd> element, in three sizes, with KbdGroup for combinations like ⌘ ⇧ K. Adapts inside tooltips.
Tooltip
A Radix tooltip with an arrow, side offset and fade and zoom transitions that opens on hover and keyboard focus. Works standalone or under a shared provider.
Accordion
Vertically stacked disclosure sections on Radix Accordion, single or multiple open, with hairline dividers, a rotating plus and height animation.
Avatar
A Radix avatar with image and initials fallback, three sizes, an optional presence dot, and AvatarGroup for overlapping stacks with a +N overflow counter.
Ballmac UI Pro
Need the whole screen, not one piece?
311 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.