Search

Jump to any page.

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/button
Brings 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.

RestHoverFocusPressedDisabledLoading
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 }