# Button

> Primary, secondary, outline, ghost, danger and link buttons in three sizes, with a loading state that manages itself.

GramproKit 2.0.0-beta · General · Beta (experimental; APIs may change) · Source: https://gramprokit.vercel.app/2.0.0-beta/button

## Installation

```bash
npx gbs-add-block@latest -a Button -beta
```

The block copies the `button` folder into your project, along with the small `shared` folder that every component imports. You own the code and can change it freely.
There are **no peer dependencies** other than React.

**Requirements**

- React 19 and `@types/react` 19
- TypeScript target ES2022 or newer, with `"jsx": "react-jsx"`
- Browsers from 2024 or newer (the styles use `light-dark()` and `color-mix()`)

Import the stylesheet once, for example in your global CSS:

```css
@import "../components/button/styles.css";
```

or in your root layout / entry file:

```ts
import "@/components/button/styles.css";
```

The Button covers every everyday action: primary and secondary actions, outlines, quiet ghost buttons, destructive actions and link-style buttons, in three sizes, with icons. Its loading state manages itself: return a promise from `onClick`, or submit a form with a Server Action, and the button shows a spinner until the work is done. It keeps its width and keyboard focus meanwhile. It can also render as another element, such as a router link, with the same look.

> **Note:** Set the --gbs-* variables on :root to theme every component at once, the grid included. Each component's own variables fall back to them, and then to the built-in palette, so components look identical out of the box.

#### Default

_Interactive demo:_ [open the live example](https://gramprokit.vercel.app/2.0.0-beta/button)

## Quick Start

```tsx
"use client";

import { Button } from "@/components/button";

export function SaveBar({ onSave }: { onSave(): Promise<void> }) {
  return (
    <div>
      <Button variant="outline">Cancel</Button>
      <Button onClick={onSave}>Save changes</Button>
    </div>
  );
}
```

`onSave` returns a promise, so the Save button shows a spinner and ignores further clicks until it settles.

> **Note:** The default type is 'button', not the browser's 'submit', so a Button inside a form never submits it by accident. Pass type='submit' for the button that should.

## Props Table

Accepts every `<button>` attribute, plus:

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"primary"` \| `"secondary"` \| `"outline"` \| `"ghost"` \| `"danger"` \| `"link"` | `"primary"` | Visual weight. |
| `size` | `"sm"` \| `"md"` \| `"lg"` | `"md"` | Height 30 / 36 / 44 px. |
| `type` | `"button"` \| `"submit"` \| `"reset"` | `"button"` | Native button type. |
| `loading` | `boolean` | `false` | Busy state: spinner, `aria-busy`, clicks ignored. |
| `loadingText` | `ReactNode` | — | Text next to the spinner while busy, in place of the label. |
| `leading` | `ReactNode` | — | Icon before the label. |
| `trailing` | `ReactNode` | — | Icon after the label. |
| `icon` | `ReactNode` | — | Makes a square icon-only button. Always add `aria-label`. |
| `fullWidth` | `boolean` | `false` | Fill the container's width. |
| `disabled` | `boolean` | `false` | Natively disabled. |
| `onClick` | `(event) => unknown` | — | Return a promise to show loading until it settles. |
| `render` | `(props: ButtonRenderProps) => ReactElement` | — | Render another element with the button's look. See [Links](#links-and-other-elements). |
| `className` | `string` | — | Class for the button. |
| `classNames` | `Partial<Record<ButtonSlot, string>>` | — | Slots: `root`, `content`, `spinner`, `icon`. |
| `localeText` | `Partial<ButtonLocaleText>` | English | See [Locale Text](#locale-text). |
| `ref` | `Ref<HTMLButtonElement>` | — | The `<button>` element. |

## Variants

```tsx
<Button>Primary</Button>                     {/* the main action on a screen */}
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>       {/* toolbars, tables, low-emphasis actions */}
<Button variant="danger">Delete</Button>     {/* destructive actions */}
<Button variant="link">Learn more</Button>   {/* looks like a link, behaves like a button */}
```

## Icons

```tsx
<Button leading={<PlusIcon />}>New invoice</Button>
<Button variant="outline" trailing={<ArrowRightIcon />}>Next</Button>
<Button variant="ghost" icon={<TrashIcon />} aria-label="Delete row" />
```

Icons are sized to the text (1.15 em). `leading` and `trailing` icons are hidden from screen readers, since the label says what the button does. An icon-only button has no visible text, so its `aria-label` is its only name.

## Loading

The loading state has three sources:

| Source | How |
| --- | --- |
| **Async `onClick`** | Return a promise: `onClick={async () => { await save(); }}` |
| **Form submission** | A `type="submit"` button inside `<form action={serverAction}>` is busy while the action runs (React 19 `useFormStatus`). |
| **Manual** | `loading={isPending}` |

```tsx
<form action={createInvoice}>
  <Input name="customer" label="Customer" required />
  <Button type="submit">Create invoice</Button>
</form>
```

- **Stays focusable:** while busy, the button uses `aria-disabled` instead of `disabled`, so keyboard focus doesn't jump elsewhere.
- **Stays put:** the label is hidden but keeps its space, so the button doesn't change width. `loadingText` swaps the label for text, and then the width can change.
- **Ignores repeats:** further clicks and form submissions are blocked until the work finishes.
- **Keeps errors visible:** if the promise rejects, the loading state ends and the error still surfaces as an unhandled rejection. Catch it in your handler to show a message.

## Links and Other Elements

To make a link look like a button, use `render`. It receives the button's class names, data attributes, click handling and content:

```tsx
import Link from "next/link";

<Button variant="outline" render={(props) => <Link href="/billing" {...props} />}>
  Go to billing
</Button>;
```

```tsx
<Button variant="link" render={(props) => <a href="/docs" target="_blank" rel="noreferrer" {...props} />}>
  Documentation
</Button>
```

Button-only attributes (`type`, `form`, `name`, `value`) are left off the rendered element. A disabled or busy link gets `aria-disabled` and ignores clicks.

## Keyboard

| Keys | Action |
| --- | --- |
| **Enter** / **Space** | Activate the button. |
| **Tab** | Move to the next control; busy buttons stay in the tab order. |

Accessibility: a real `<button>` by default. While busy, it has `aria-busy="true"` and announces "Loading" through visually hidden text. `leading` and `trailing` icons are `aria-hidden`. Focus shows as a 2 px ring on keyboard focus only (`:focus-visible`).

## Styling and Theming

All rules are in the CSS `components` layer, so utility classes passed through `className` / `classNames` override them.

```tsx
<Button className="rounded-full px-6" />
```

#### CSS variables

Override them on `.bt-root`, on `:root`, or through `style`. Each variable falls back to the shared `--gbs-*` of the same name, then to the DataGrid's `--dg-*` when that stylesheet is loaded, and finally to the built-in palette.

| Variable | Used for |
| --- | --- |
| `--bt-height`, `--bt-px`, `--bt-gap`, `--bt-font-size` | Size (set by `size`). |
| `--bt-fill`, `--bt-fill-hover`, `--bt-ink`, `--bt-line` | Background, hover background, text and border (set by `variant`). |
| `--bt-accent`, `--bt-accent-fg` | The `primary` variant and `link` text. |
| `--bt-danger`, `--bt-danger-fg` | The `danger` variant. |
| `--bt-subtle` | The `secondary` background. |
| `--bt-border`, `--bt-hover` | `outline` border and hover background. |
| `--bt-focus` | Focus ring. |
| `--bt-radius` | Corner radius. |

```css
/* A brand variant, without touching the component */
.bt-root.brand {
  --bt-fill: #7c3aed;
  --bt-fill-hover: #6d28d9;
  --bt-line: #7c3aed;
  --bt-ink: white;
}
```

#### Data attributes

| Element | Attributes |
| --- | --- |
| Root (`.bt-root`) | `data-variant`, `data-size`, `data-busy`, `data-icon-only`, `data-full-width`, `aria-busy`, `aria-disabled` |

## Locale Text

```tsx
<Button localeText={{ loading: "Wird geladen" }} />
```

| Key | Default |
| --- | --- |
| `loading` | `"Loading"` — announced while busy, unless `loadingText` is given |

## Headless Use

| Export | Description |
| --- | --- |
| `buttonState({ disabled, loading, pending })` | Whether the button is busy, ignores activation, and uses native `disabled`. |
| `isPromiseLike(value)` | Detects an async click handler's result. |

## Next.js

The Button is a client component with `"use client"` at the top of its file.

- **In Server Components:** it works as long as you pass no function props. A `type="submit"` Button inside `<form action={serverAction}>` needs none and shows progress on its own, so it works on a Server Component page.
- **With `onClick` or `render`:** these are functions, which a Server Component can't pass to a client component. Use them inside a client component, for example to wrap `next/link` with `render`.

## Migrating from the Previous Custom Button

| Previous | New |
| --- | --- |
| `CustomBtn` | `Button` |
| `value="Submit"` (the label) | `children`: `<Button>Submit</Button>` |
| `id`, `name`, `className`, `disabled`, `onClick` | Same names |
| `type` (default `submit` in the browser) | `type`, now defaulting to `button`; pass `type="submit"` where needed |
| `buttonStyles` from `globalStyle.ts` (`primary`, `secondary`, `outline`, `ghost`, `danger`; `sm`, `md`, `lg`) | `variant` and `size` with the same names, plus `link` |
| — | New: `loading`, async `onClick`, form-action pending state, icons, `fullWidth`, `render` |

#### Notes

- Icon-only buttons need an `aria-label`; the component can't infer one from an icon.
- `render` must spread the props it receives onto the element, or the look and click handling are lost.
