Actions
Button
Ink, brand, outline, glass and link buttons with spring press, loading state and a direction-aware arrow.
pnpm dlx shadcn@latest add @paragon/buttonBrings in@paragon/spinner@base-ui/reactclass-variance-authoritylucide-reactcn
States
Every variant pinned in each state. Press springs to 97% and back; focus is a 2px offset ring that survives every surface.
| Rest | Hover | Focus | Pressed | Disabled | Loading | |
|---|---|---|---|---|---|---|
| default | ||||||
| brand | ||||||
| outline | ||||||
| secondary | ||||||
| ghost | ||||||
| glass | ||||||
| destructive |
Loading
The label fades out under a spinner so the width never jumps, and the button keeps focus while busy.
Arrow
ButtonArrow nudges forward on hover and flips under RTL.
registry/paragon/ui/button.tsx
import * as React from "react"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { ArrowRight } from "lucide-react"
import { cn } from "cn"
import { Spinner } from "@/registry/paragon/ui/spinner"
const buttonVariants = cva(
[
"group/button relative inline-flex shrink-0 touch-manipulation items-center justify-center rounded-button border border-transparent bg-clip-padding font-medium tracking-[-0.01em] whitespace-nowrap outline-none select-none",
// Colours settle on the Paragon ease; scale springs back with a slight overshoot.
"[transition:background-color_var(--duration-base)_var(--ease-paragon),border-color_var(--duration-base)_var(--ease-paragon),color_var(--duration-base)_var(--ease-paragon),box-shadow_var(--duration-base)_var(--ease-paragon),opacity_var(--duration-base)_var(--ease-paragon),scale_var(--duration-slow)_var(--ease-spring)]",
"active:not-aria-[haspopup]:scale-[0.97] active:[transition-duration:var(--duration-fast)] motion-reduce:active:scale-100",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:outline-solid",
"data-disabled:not-data-loading:pointer-events-none data-disabled:not-data-loading:opacity-45 data-loading:cursor-progress",
"aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20",
"[&_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_rgb(255_255_255/0.1)] hover:bg-[color-mix(in_oklab,var(--primary),var(--background)_16%)]",
brand:
"bg-brand text-brand-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.14)] hover:bg-[color-mix(in_oklab,var(--brand),var(--foreground)_12%)]",
outline:
"border-input bg-transparent hover:border-foreground/40 hover:bg-foreground/4 aria-expanded:bg-foreground/4 data-popup-open:bg-foreground/4",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklab,var(--secondary),var(--foreground)_6%)] data-popup-open:bg-[color-mix(in_oklab,var(--secondary),var(--foreground)_6%)]",
ghost: "hover:bg-foreground/5 aria-expanded:bg-foreground/5 data-popup-open:bg-foreground/5",
glass:
"bg-white/55 text-ink backdrop-blur-md hover:bg-white/75 dark:bg-white/10 dark:text-night-text dark:hover:bg-white/16",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/16 focus-visible:outline-destructive",
link: "h-auto! rounded-none border-0 border-b border-current px-0! pb-0.5 hover:border-transparent active:scale-100",
},
size: {
xs: "h-7 gap-1 rounded-[calc(var(--radius-button)-4px)] px-2.5 text-xs pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 [&_svg:not([class*='size-'])]:size-3.5",
sm: "h-9 gap-1.5 rounded-[calc(var(--radius-button)-2px)] px-3.5 text-sm pointer-coarse:after:absolute pointer-coarse:after:-inset-1",
default: "h-[2.625rem] gap-2 px-[1.125rem] text-[0.9375rem]",
lg: "h-12 gap-2.5 px-[1.375rem] text-base",
icon: "size-[2.625rem]",
"icon-xs":
"size-7 rounded-[calc(var(--radius-button)-4px)] pointer-coarse:after:absolute pointer-coarse:after:-inset-2 [&_svg:not([class*='size-'])]:size-3.5",
"icon-sm":
"size-9 rounded-[calc(var(--radius-button)-2px)] pointer-coarse:after:absolute pointer-coarse:after:-inset-1",
"icon-lg": "size-12",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
/**
* `loading` keeps the button focused and its width fixed: the label fades
* out, a spinner fades in, and `aria-busy` tells assistive tech why.
*/
function Button({
className,
variant = "default",
size = "default",
loading = false,
disabled,
focusableWhenDisabled,
children,
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants> & { loading?: boolean }) {
return (
<ButtonPrimitive
data-slot="button"
data-loading={loading || undefined}
aria-busy={loading || undefined}
disabled={disabled || loading}
focusableWhenDisabled={focusableWhenDisabled || loading}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
>
{loading ? (
<Spinner
aria-hidden="true"
role={undefined}
className="absolute inset-0 m-auto animate-in duration-(--duration-base) fade-in zoom-in-50"
/>
) : null}
<span
data-slot="button-label"
className="inline-flex items-center justify-center gap-[inherit] transition-opacity duration-(--duration-base) ease-paragon group-data-loading/button:opacity-0"
>
{children}
</span>
</ButtonPrimitive>
)
}
/** Trailing arrow that nudges forward on hover and follows reading direction. */
function ButtonArrow({ className, ...props }: React.ComponentProps<typeof ArrowRight>) {
return (
<ArrowRight
data-icon="inline-end"
aria-hidden="true"
className={cn(
"transition-[translate] duration-(--duration-slow) ease-paragon group-hover/button:translate-x-0.5 motion-reduce:transition-none rtl:-scale-x-100 rtl:group-hover/button:-translate-x-0.5",
className
)}
{...props}
/>
)
}
export { Button, ButtonArrow, buttonVariants }