# Button

Triggers an action. Sixteen variants — solid, soft, outline, ghost and link — four sizes, each with a square one for an icon alone, a loading state, and can render as a link.

Docs: https://dashral.com/components/button

## Installation

### shadcn CLI

Adds the component, the components it builds on, its npm packages and the theme tokens it uses:

```bash
npx shadcn@latest add https://dashral.com/r/button.json
```

With the `@dashral` registry in `components.json`: `npx shadcn@latest add @dashral/button`

### Manual

Install the dependencies:

```bash
npm install class-variance-authority clsx lucide-react radix-ui tailwind-merge
```

Copy the source below into `components/dashral/button.tsx`. It needs the `cn()` helper in `components/dashral/lib/utils.ts` and the Dashral theme tokens (`https://dashral.com/r/dashral-theme.json`).

## Usage

```tsx
import { Button } from "@/components/dashral/button"

<>
  <Button variant="outline">Cancel</Button>
  <Button loading={saving} onClick={() => api.save()}>
    Save changes
  </Button>
</>
```

## Examples

### Variants

How much a button stands out, from solid down to link.

```tsx
import { Button } from "@/components/dashral/button"

export default function ButtonVariants() {
  return (
    <div className="flex flex-wrap items-center gap-2">
      <Button>Solid</Button>
      <Button variant="soft">Soft</Button>
      <Button variant="outline">Outline</Button>
      <Button variant="ghost">Ghost</Button>
      <Button variant="link">Link</Button>
    </div>
  )
}
```

### Colors

Every variant comes in every color. Outline and ghost default to neutral, the rest to primary.

```tsx
import { Button } from "@/components/dashral/button"

const variants = ["solid", "soft", "outline", "ghost"] as const
const colors = ["primary", "neutral", "info", "success", "warning", "destructive", "highlight"] as const

export default function ButtonColors() {
  return (
    <div className="w-full overflow-x-auto">
      <div className="mx-auto grid w-max grid-cols-[auto_repeat(7,auto)] items-center gap-2">
        <span />
        {colors.map((color) => (
          <span key={color} className="text-center text-xs text-muted-foreground capitalize">
            {color}
          </span>
        ))}
        {variants.map((variant) => (
          <div key={variant} className="contents">
            <span className="pe-2 text-xs text-muted-foreground capitalize">{variant}</span>
            {colors.map((color) => (
              <Button key={color} variant={variant} color={color} size="sm">
                Button
              </Button>
            ))}
          </div>
        ))}
      </div>
    </div>
  )
}
```

### Glow

A soft colored shadow under a solid button, for the one action that matters most.

```tsx
import { ArrowRight, Sparkles } from "lucide-react"
import { Button } from "@/components/dashral/button"

export default function ButtonGlow() {
  return (
    <div className="flex flex-wrap items-center gap-4">
      <Button glow>
        Get started <ArrowRight />
      </Button>
      <Button glow color="highlight">
        <Sparkles /> Upgrade to Pro
      </Button>
      <Button glow color="success">
        Publish
      </Button>
    </div>
  )
}
```

### Sizes

xs, sm, default and lg, each with a square icon size at the same height: icon-xs, icon-sm, icon and icon-lg.

```tsx
import { ArrowUpRight } from "lucide-react"
import { Button } from "@/components/dashral/button"

export default function ButtonSizes() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-x-8 gap-y-4">
      <div className="flex items-center gap-2">
        <Button variant="outline" size="xs">
          Extra small
        </Button>
        <Button variant="outline" size="icon-xs" aria-label="Open">
          <ArrowUpRight />
        </Button>
      </div>
      <div className="flex items-center gap-2">
        <Button variant="outline" size="sm">
          Small
        </Button>
        <Button variant="outline" size="icon-sm" aria-label="Open">
          <ArrowUpRight />
        </Button>
      </div>
      <div className="flex items-center gap-2">
        <Button variant="outline">Default</Button>
        <Button variant="outline" size="icon" aria-label="Open">
          <ArrowUpRight />
        </Button>
      </div>
      <div className="flex items-center gap-2">
        <Button variant="outline" size="lg">
          Large
        </Button>
        <Button variant="outline" size="icon-lg" aria-label="Open">
          <ArrowUpRight />
        </Button>
      </div>
    </div>
  )
}
```

### With icon

```tsx
import { Download, Trash2 } from "lucide-react"
import { Button } from "@/components/dashral/button"

export default function ButtonWithIcon() {
  return (
    <div className="flex flex-wrap items-center gap-2">
      <Button variant="outline">
        <Download /> Export
      </Button>
      <Button color="destructive">
        <Trash2 /> Delete
      </Button>
    </div>
  )
}
```

### Loading

loading shows a spinner in place of the label (with loadingText beside it), keeping the button's width, and ignores clicks without losing focus.

```tsx
"use client"

import { useState } from "react"
import { Save } from "lucide-react"
import { Button } from "@/components/dashral/button"

export default function ButtonLoading() {
  const [saving, setSaving] = useState(false)
  return (
    <div className="grid justify-items-center gap-3">
      <Button
        loading={saving}
        loadingText="Saving…"
        onClick={() => {
          setSaving(true)
          setTimeout(() => setSaving(false), 2000)
        }}
      >
        <Save />
        Save changes
      </Button>
      <p className="text-xs text-muted-foreground">While loading it keeps its width, ignores clicks and keeps keyboard focus.</p>
    </div>
  )
}
```

### As a link

Use asChild to apply button styles to another element.

```tsx
import Link from "next/link"
import { Button } from "@/components/dashral/button"

// asChild passes the button styles to its child — here a Next.js <Link>.
export default function ButtonAsLink() {
  return (
    <Button asChild variant="outline">
      <Link href="/pricing">View pricing</Link>
    </Button>
  )
}
```

## API reference

### Button

Also takes every prop of [<button>](https://developer.mozilla.org/docs/Web/HTML/Element/button).

| Prop | Type | Default | Description |
|---|---|---|---|
| `variant` | `"solid" \| "soft" \| "outline" \| "ghost" \| "link"` | `"solid"` | How much it stands out. |
| `color` | `"primary" \| "neutral" \| "info" \| "success" \| "warning" \| "destructive" \| "highlight"` | `"primary" (outline, ghost: "neutral")` | What it means. |
| `glow` | `boolean` | `false` | A soft colored shadow under a solid button. |
| `size` | `"xs" \| "sm" \| "default" \| "lg" \| "icon-xs" \| "icon-sm" \| "icon" \| "icon-lg" \| "inline"` | `"default"` | Height and padding; each icon size is square, as tall as the size it's named after; inline has neither, for a link-style button in a sentence. |
| `loading` | `boolean` | `false` | Shows a spinner in place of the label, which stays (hidden) so the button keeps its width, and ignores clicks, but stays focusable. |
| `loadingText` | `ReactNode` | — | Beside the spinner while loading, e.g. "Saving…"; the button is as wide as the wider of it and the label. Not with asChild. |
| `asChild` | `boolean` | `false` | Render the child element instead (e.g. a Next.js Link), passing these styles and props to it. |
| `className` | `string` | — | Extra classes, merged with cn() so yours win. |

### buttonVariants

The class builder, to style a link or another element like a button: buttonVariants({ variant, color, size }).

## Keyboard

| Keys | Action |
|---|---|
| Enter / Space | Activate |

## Accessibility

- A native button, so focus, Enter and Space come built in. Give icon-only buttons an aria-label.
- loading sets aria-busy and aria-disabled instead of disabled, so focus isn't lost mid-action and a submit button doesn't submit its form twice.

## Motion

- Presses in to 97% while held (--duration-fast, --ease-standard). Off with reduced motion, for disabled buttons, while loading, and on a button that opens a menu, popover or tooltip (Radix places the layer from it as it opens).

## Source

```tsx
import * as React from "react"
import { Slot } from "radix-ui"
import { cva } from "class-variance-authority"
import { Loader2 } from "lucide-react"

import { cn } from "@/components/dashral/lib/utils"

type ButtonVariant = "solid" | "soft" | "outline" | "ghost" | "link"
type ButtonColor = "primary" | "neutral" | "info" | "success" | "warning" | "destructive" | "highlight"

/** The color a variant takes when none is given: quiet variants are neutral, the rest primary. */
const defaultColors: Record<ButtonVariant, ButtonColor> = {
  solid: "primary",
  soft: "primary",
  outline: "neutral",
  ghost: "neutral",
  link: "primary",
}

// Every class is written out in full so Tailwind finds it.
const tones: Record<ButtonVariant, Record<ButtonColor, string>> = {
  solid: {
    primary: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
    neutral: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
    info: "bg-info text-info-foreground shadow-xs hover:bg-info/90",
    success: "bg-success text-success-foreground shadow-xs hover:bg-success/90",
    warning: "bg-warning text-warning-foreground shadow-xs hover:bg-warning/90",
    destructive: "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/30",
    highlight: "bg-highlight text-highlight-foreground shadow-xs hover:bg-highlight/90",
  },
  soft: {
    primary: "bg-primary/12 text-primary-text hover:bg-primary/18",
    neutral: "bg-foreground/6 text-foreground hover:bg-foreground/10",
    info: "bg-info/12 text-info-text hover:bg-info/18",
    success: "bg-success/12 text-success-text hover:bg-success/18",
    warning: "bg-warning/18 text-warning-text hover:bg-warning/25",
    destructive: "bg-destructive/10 text-destructive-text hover:bg-destructive/15",
    highlight: "bg-highlight/12 text-highlight-text hover:bg-highlight/18",
  },
  outline: {
    primary: "border border-primary/40 bg-card text-primary-text shadow-xs hover:bg-primary/8",
    neutral: "border bg-card shadow-xs hover:bg-accent hover:text-accent-foreground",
    info: "border border-info/40 bg-card text-info-text shadow-xs hover:bg-info/8",
    success: "border border-success/40 bg-card text-success-text shadow-xs hover:bg-success/8",
    warning: "border border-warning/50 bg-card text-warning-text shadow-xs hover:bg-warning/10",
    destructive: "border border-destructive/40 bg-card text-destructive-text shadow-xs hover:bg-destructive/8",
    highlight: "border border-highlight/40 bg-card text-highlight-text shadow-xs hover:bg-highlight/8",
  },
  ghost: {
    primary: "text-primary-text hover:bg-primary/10",
    neutral: "hover:bg-accent hover:text-accent-foreground",
    info: "text-info-text hover:bg-info/10",
    success: "text-success-text hover:bg-success/10",
    warning: "text-warning-text hover:bg-warning/12",
    destructive: "text-destructive-text hover:bg-destructive/10",
    highlight: "text-highlight-text hover:bg-highlight/10",
  },
  link: {
    primary: "text-primary-text underline-offset-4 hover:underline",
    neutral: "text-foreground underline-offset-4 hover:underline",
    info: "text-info-text underline-offset-4 hover:underline",
    success: "text-success-text underline-offset-4 hover:underline",
    warning: "text-warning-text underline-offset-4 hover:underline",
    destructive: "text-destructive-text underline-offset-4 hover:underline",
    highlight: "text-highlight-text underline-offset-4 hover:underline",
  },
}

/** A soft colored glow under a solid button, for the one action that matters most on a page. */
const glows: Record<ButtonColor, string> = {
  primary: "shadow-lg shadow-primary/30 hover:shadow-primary/40",
  neutral: "shadow-lg shadow-foreground/10",
  info: "shadow-lg shadow-info/30 hover:shadow-info/40",
  success: "shadow-lg shadow-success/30 hover:shadow-success/40",
  warning: "shadow-lg shadow-warning/30 hover:shadow-warning/40",
  destructive: "shadow-lg shadow-destructive/30 hover:shadow-destructive/40",
  highlight: "shadow-lg shadow-highlight/30 hover:shadow-highlight/40",
}

// It presses in a little, except where it anchors a menu, popover, tooltip or listbox (Radix marks those with data-state
// or aria-haspopup): Radix measures the trigger as it opens, and a shrinking one would place the layer a hair off. A
// Motion button (@dashral/motion, data-press="motion") presses on its own spring instead.
const buttonBase = cva(
  "inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all outline-none motion-safe:enabled:not-aria-busy:not-data-[state]:not-data-[press=motion]:not-[[aria-haspopup]]:not-[[role=combobox]]:active:scale-[0.97] aria-busy:cursor-progress focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:ring-destructive/15 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  {
    variants: {
      size: {
        // Each size has a square one for an icon alone, at the same height: xs and icon-xs, sm and icon-sm, and so on.
        // With an icon beside the label (however deep it sits) the sides tighten; the loading spinner doesn't count, so the
        // width holds while it spins.
        xs: "h-7 gap-1 rounded-md px-2.5 text-xs has-[svg:not([data-spinner])]:px-2 [&_svg:not([class*='size-'])]:size-3.5",
        sm: "h-8 gap-1.5 rounded-md px-3 has-[svg:not([data-spinner])]:px-2.5",
        default: "h-9 px-4 py-2 has-[svg:not([data-spinner])]:px-3",
        lg: "h-10 rounded-md px-6 has-[svg:not([data-spinner])]:px-4",
        "icon-xs": "size-7 [&_svg:not([class*='size-'])]:size-3.5",
        "icon-sm": "size-8",
        icon: "size-9",
        "icon-lg": "size-10",
        // Text-sized, for a link-style button inside a sentence or a row of small text.
        inline: "h-auto gap-1 rounded-sm p-0 text-[length:inherit]",
      },
    },
    defaultVariants: { size: "default" },
  }
)

type ButtonStyleProps = {
  /** How much the button stands out: solid, soft, outline, ghost or link. */
  variant?: ButtonVariant | null
  /** Its color. Solid, soft and link default to primary; outline and ghost to neutral. */
  color?: ButtonColor | null
  size?: "xs" | "sm" | "default" | "lg" | "icon-xs" | "icon-sm" | "icon" | "icon-lg" | "inline" | null
  /** A soft colored glow under a solid button. */
  glow?: boolean
  className?: string
}

/** The classes for a button, for an element that should look like one (a Link, a menu trigger). */
function buttonVariants({ variant, color, size, glow, className }: ButtonStyleProps = {}) {
  const kind = variant ?? "solid"
  const tone = color ?? defaultColors[kind]
  return cn(buttonBase({ size }), tones[kind][tone], glow && kind === "solid" && glows[tone], className)
}

const spin = "animate-spin motion-reduce:animate-[spin_1.5s_linear_infinite]"
/**
 * A layer of the label's grid cell: laid out like the button itself, its gap and justify included, and as wide as the
 * button, so a className's justify-between, a child's ms-auto or flex-1, and truncate all work as on a plain flex row.
 */
const layer = "col-start-1 row-start-1 inline-flex min-w-0 items-center [justify-content:inherit] gap-[inherit]"

/**
 * A button: variant says how much it stands out, color what it means. Presses in slightly when clicked (not with
 * reduced motion). `loading` shows a spinner (and `loadingText`) in place of the label, the button keeping its width,
 * and ignores clicks, but keeps the button focusable (aria-busy and aria-disabled instead of disabled), so keyboard
 * users don't lose their place.
 */
function Button({
  className,
  variant,
  color,
  size,
  glow,
  asChild = false,
  loading = false,
  loadingText,
  children,
  onClick,
  ...props
}: Omit<React.ComponentProps<"button">, "color"> &
  Omit<ButtonStyleProps, "className"> & {
    asChild?: boolean
    /** Shows a spinner in place of the label and ignores clicks, without moving focus. */
    loading?: boolean
    /** Beside the spinner while loading, e.g. "Saving…"; without it the spinner shows alone. Not with asChild. */
    loadingText?: React.ReactNode
  }) {
  const Comp = asChild ? Slot.Root : "button"

  return (
    <Comp
      data-slot="button"
      {...props}
      className={buttonVariants({ variant, color, size, glow, className })}
      aria-busy={loading || undefined}
      aria-disabled={loading || props["aria-disabled"]}
      // preventDefault also stops a submit button from submitting its form.
      onClick={loading ? (event: React.MouseEvent<HTMLButtonElement>) => event.preventDefault() : onClick}
    >
      {/* asChild: Slot looks for Slottable among the direct children (not in a fragment), and can't wrap its child, so the
          spinner sits before it. */}
      {asChild && loading && <Loader2 data-spinner="" className={spin} aria-hidden />}
      {asChild ? (
        <Slot.Slottable>{children}</Slot.Slottable>
      ) : (
        // The label and the loading state share one grid cell, so the button is as wide as the wider of the two and never
        // jumps as it starts or stops loading; the label stays in place, hidden, while the spinner (and loadingText) show.
        // The cell fills the button (one 1fr column) and passes its justify on to the layers.
        <span className="grid w-full min-w-0 grid-cols-1 [justify-content:inherit] gap-[inherit]">
          <span className={cn(layer, loading && "invisible")}>{children}</span>
          {loading && (
            <span className={layer}>
              <Loader2 data-spinner="" className={spin} aria-hidden />
              {loadingText}
            </span>
          )}
        </span>
      )}
    </Comp>
  )
}

export { Button, buttonVariants, type ButtonVariant, type ButtonColor }
```