rapidUI

Button

Button with default, outline, secondary, ghost, destructive and link variants, sm, default and lg sizes plus icon sizes; renders as a link or another element with `render`.

Installation

pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/button.json

Installs to @/components/ui/button.

Manual installation

Install the dependencies:

pnpm add @base-ui/react@^1.8.0 class-variance-authority@^0.7.1

Copy the source into your project:

components/ui/button.tsx
import { Button as ButtonPrimitive } from '@base-ui/react/button'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
import { cn } from '@/lib/utils'

const buttonVariants = cva(
	"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring cursor-pointer disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 data-disabled:cursor-not-allowed data-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
	{
		variants: {
			variant: {
				// Colours kept from the previous Rapid button (ghost is shadcn's), with a pressed background instead of
				// shadcn's press-down nudge; sizes, focus and states are shadcn's
				default: 'bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary/80',
				outline:
					'border-input bg-background text-foreground hover:bg-accent active:bg-[color-mix(in_oklch,var(--accent),var(--foreground)_6%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
				secondary:
					'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--secondary-foreground)_8%)] active:bg-[color-mix(in_oklch,var(--secondary),var(--secondary-foreground)_16%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
				ghost:
					'hover:bg-muted hover:text-foreground active:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_10%)] aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
				destructive: 'bg-destructive text-white hover:bg-destructive/90 active:bg-destructive/80',
				// Quiet at rest so a delete sits at the same weight as the actions beside it; the destructive colour arrives on
				// hover and focus, where the intent is real
				'ghost-destructive':
					'hover:bg-destructive/10 hover:text-[color-mix(in_oklab,var(--destructive)_80%,black)] focus-visible:text-[color-mix(in_oklab,var(--destructive)_80%,black)] active:bg-destructive/20 aria-expanded:bg-destructive/10 aria-expanded:text-destructive dark:hover:bg-destructive/20 dark:hover:text-[color-mix(in_oklab,var(--destructive)_50%,white)] dark:focus-visible:text-[color-mix(in_oklab,var(--destructive)_50%,white)]',
				link: 'text-primary underline underline-offset-4',
			},
			size: {
				default: 'h-10 gap-2 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
				xs: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
				sm: "h-8 gap-1.5 rounded-[min(var(--radius-md),12px)] px-3 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
				lg: 'h-12 gap-2 px-6 has-data-[icon=inline-end]:pr-5 has-data-[icon=inline-start]:pl-5',
				icon: 'size-10',
				'icon-xs':
					"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
				'icon-sm': 'size-8 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
				'icon-lg': 'size-12',
			},
		},
		defaultVariants: {
			variant: 'default',
			size: 'default',
		},
	},
)

// Base UI's Button defaults to type="button" on a native <button>, so buttons never submit a form by accident.
// Render a link with `render={<a href="…" />}` and `nativeButton={false}`. Base UI would give it role="button"; we drop
// that role for <a> elements so a link keeps its link semantics.
function Button({
	className,
	variant = 'default',
	size = 'default',
	render,
	...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
	const isLink = React.isValidElement(render) && render.type === 'a' && !('role' in props)

	return (
		<ButtonPrimitive
			data-slot="button"
			data-variant={variant}
			data-size={size}
			className={cn(buttonVariants({ variant, size, className }))}
			render={render}
			{...(isLink ? { role: undefined } : null)}
			{...props}
		/>
	)
}

export { Button, buttonVariants }

A versatile button component that supports multiple variants, sizes, and states. Built on the Base UI Button and styled with Tailwind CSS using class-variance-authority for dynamic variants.

Features

  • Seven visual variants: default, outline, secondary, ghost, destructive, ghost-destructive, link
  • Text sizes (xs, sm, default, lg) and square icon sizes (icon-xs, icon-sm, icon, icon-lg)
  • Icon spacing handled automatically via data-icon="inline-start" / data-icon="inline-end"
  • Polymorphic composition with the render prop (e.g. render a link)
  • Exposes data-slot="button", data-variant and data-size for styling hooks
  • Integrates with ButtonGroup (rounded corners adapt inside a group)
  • Full keyboard navigation and visible focus ring
  • aria-invalid styling for form validation

Usage

import { Button } from '@/components/ui/button'
import { ArrowRight, Download, Printer } from 'lucide-react'

export function DecisionActions() {
  return (
    <div className="flex flex-wrap gap-2">
      {/* Primary action */}
      <Button>Send application</Button>

      {/* With variant and size */}
      <Button variant="outline" size="lg">
        Save draft
      </Button>

      {/* With icons */}
      <Button variant="secondary">
        <Download data-icon="inline-start" />
        Download decision
      </Button>
      <Button variant="outline">
        Continue
        <ArrowRight data-icon="inline-end" />
      </Button>

      {/* Icon-only */}
      <Button size="icon" variant="ghost" aria-label="Print decision">
        <Printer />
      </Button>

      {/* As link */}
      <Button render={<a href="/my-pages" />} nativeButton={false}>
        Go to My pages
      </Button>
    </div>
  )
}

Examples

Default Button

The default button style with primary color, for the main action on a page.

All Variants

Each variant with the kind of action it suits.

Disabled

Disabled buttons in the footer of an application that isn’t signed yet. Explain nearby why the action isn’t available.

Sizes

Text button sizes, from xs to lg.

Icon Sizes

Square sizes for icon-only buttons, and a toolbar of ghost icon buttons. Always provide an aria-label.

Row Actions

ghost-destructive is for a delete action that sits beside other actions. It is as quiet as ghost at rest, so it doesn't draw the eye in every row, and turns destructive on hover and keyboard focus. Name each icon button with aria-label, including what it acts on. The label and icon carry the meaning, not the red, and a delete that can't be undone should still ask for confirmation, for example in an AlertDialog.

<Button size="icon-sm" variant="ghost" aria-label="Edit case 2026-04812">
  <PencilIcon />
</Button>
<Button size="icon-sm" variant="ghost" aria-label="Duplicate case 2026-04812">
  <CopyIcon />
</Button>
<Button size="icon-sm" variant="ghost-destructive" aria-label="Delete case 2026-04812">
  <Trash2Icon />
</Button>

With Icons

Mark icons with data-icon="inline-start" or data-icon="inline-end" so the button adjusts its padding. SVGs are sized automatically unless you pass a size-* class.

Loading State

There is no built-in loading prop. Compose a spinning icon and disable the button while the action runs.

<Button disabled>
  <Loader2 data-icon="inline-start" className="animate-spin" />
  Sending application…
</Button>

Use the render prop to render the button as another element, such as a link. Set nativeButton={false} whenever the rendered element is not a <button>, so the button doesn't add button semantics to it.

<Button render={<a href="/my-pages" />} nativeButton={false}>
  Go to My pages
</Button>

Form Actions

A typical footer in a multi-step e-service: a ghost Back button on the left, and Save draft and Continue on the right.

Props

  • variant - 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'ghost-destructive' | 'link' (default 'default')
  • size - 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg' (default 'default')
  • render - React.ReactElement | (props, state) => React.ReactElement, the element to render instead of <button>
  • nativeButton - boolean (default true), set to false when render is not a <button>
  • focusableWhenDisabled - boolean (default false), keeps a disabled button in the tab order

All native button attributes are forwarded. type defaults to "button" so a button never submits a form by accident; set type="submit" on the submit button. buttonVariants is also exported for styling other elements as buttons.

Variants

  • default - Primary action, high emphasis
  • outline - Secondary actions, medium emphasis, bordered
  • secondary - Alternative filled style with lower emphasis
  • ghost - Minimal emphasis, transparent background
  • destructive - Dangerous or destructive actions (solid red)
  • ghost-destructive - Transparent like ghost, red on hover and focus; for a delete beside other actions
  • link - Appears as a text link

Sizes

  • xs - Compact, with smaller text and padding (32px)
  • sm - Compact size for tight spaces (32px)
  • default - Standard size for most use cases (40px)
  • lg - Large size for high emphasis (48px)
  • icon-sm (32px), icon (40px), icon-lg (48px) - Square formats for icon-only buttons
  • icon-xs (24px) - For small icon buttons inside fields and chips

No button is shorter than 32px except icon-xs, which keeps a 24px clickable area.

Accessibility

The Button component follows WAI-ARIA guidelines:

  • Uses native button element by default
  • Supports keyboard navigation
  • Maintains focus states
  • Communicates disabled states to assistive technology
  • Links rendered with render and nativeButton={false} keep their link semantics

Best Practices

  1. Use appropriate variants for different actions:

    • default for primary actions
    • destructive for dangerous operations
    • ghost-destructive for a delete in a row or toolbar of quieter actions
    • outline, secondary or ghost for secondary actions
  2. Include descriptive text:

    • Use clear, action-oriented labels
    • Avoid generic text like "Click here"
    • Keep text concise but meaningful
  3. Icon usage:

    • Use icons to reinforce meaning
    • Mark leading/trailing icons with data-icon
    • Provide an aria-label for icon-only buttons
  4. Loading states:

    • Show a spinning icon for async actions
    • Disable the button while loading
    • Keep the label so the button width stays stable
  5. Accessibility:

    • Ensure sufficient color contrast
    • Maintain focus visibility
  • ButtonGroup
  • Toggle
  • Spinner