# Button

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

GramproKit 2.9.3 · Inputs · Beta (experimental; APIs may change) · Source: https://kit.gramproindia.com/button

Also available for React Native (iOS and Android, beta): https://kit.gramproindia.com/native/docs/button.md

## Installation

```bash
npx @grampro/kit add Button
```

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. The files land in `component-lib/`, or `src/component-lib/` when your project has a `src/` folder; pass `--dest` to put them somewhere else, and later installs follow the first.
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 "../component-lib/gbs.css";
```

or in your root layout / entry file:

```ts
import "@/component-lib/gbs.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://kit.gramproindia.com/button)

## Quick Start

```tsx
"use client";

import { Button } from "@/component-lib/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 */}
```

Every variant and size together, with an icon-only button, a disabled one, an async `onClick`, a controlled `loading` and a button rendered as a link. The async button keeps its width and its focus while it works.

_Interactive demo:_ [open the live example](https://kit.gramproindia.com/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

Rules ship **unlayered**, so a framework reset cannot strip them and a utility class passed through `className` still overrides them. Import the generated `gbs.css` once — see [Theming](https://kit.gramproindia.com/theming#styles-and-cascade-layers).

```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 |

## Core Exports

| 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.
