---
title: Button
description: Initiates an action, such as completing a task or submitting information.
---

# Button

Initiates an action, such as completing a task or submitting information.

Every `Button` states what it means: `appearance` (visual weight) and `intent` (tone) are **required**; there are no implicit defaults, so every call site declares both.

The primary action button should be `appearance="filled" intent="neutral"` — the system's default primary action. Reserve `intent="accent"` for deliberate brand emphasis.

```tsx
import { Button } from "@ngrok/mantle/button";

<Button appearance="outlined" intent="neutral">Outlined</Button>
<Button appearance="filled" intent="neutral">Filled</Button>
<Button appearance="ghost" intent="neutral">Ghost</Button>
<Button appearance="link" intent="neutral">Link</Button>

<Button appearance="outlined" intent="accent">Outlined</Button>
<Button appearance="filled" intent="accent">Filled</Button>
<Button appearance="ghost" intent="accent">Ghost</Button>
<Button appearance="link" intent="accent">Link</Button>

<Button appearance="outlined" intent="danger">Outlined</Button>
<Button appearance="filled" intent="danger">Filled</Button>
<Button appearance="ghost" intent="danger">Ghost</Button>
<Button appearance="link" intent="danger">Link</Button>
```

## Size

Use the `size` prop to control the height and horizontal padding of the button, default `md`. The scale is shared with [IconButton](/components/actions/icon-button) and [SplitButton](/components/actions/split-button): the same size name always renders the same box height, so mixed compositions stay aligned.

Only the box scales: every size keeps the same typography (`text-sm font-medium`) and icon glyph size.

`size` has no effect when `appearance="link"`: link buttons inherit the surrounding typography and have no box to size.

```tsx
import { Button } from "@ngrok/mantle/button";

<Button appearance="outlined" intent="neutral" size="xs">Extra Small</Button>
<Button appearance="outlined" intent="neutral" size="sm">Small</Button>
<Button appearance="outlined" intent="neutral" size="md">Medium</Button>
<Button appearance="outlined" intent="neutral" size="lg">Large</Button>
<Button appearance="outlined" intent="neutral" size="xl">Extra Large</Button>
```

## Type

Unlike the native `<button>` element (which defaults to `type="submit"`), a mantle `Button` defaults to **`type="button"`**. This matches the wider React ecosystem (Radix, shadcn, MUI, …) and stops a button from accidentally submitting a surrounding `<form>` when all you wanted was an `onClick`.

The tradeoff: a button that *should* submit a form has to opt in with `type="submit"` (or `type="reset"` to reset it). A plain `<Button>` that relies on native form submission will silently do nothing.

```tsx
// ✅ Opts in — submits the form
<Button type="submit" appearance="filled" intent="neutral">Save</Button>

// ❌ Defaults to type="button" — native form submission never fires
<Button appearance="filled" intent="neutral">Save</Button>
```

When `asChild` is used, `type` has no effect and is not forwarded to the child, so a wrapped anchor never inherits a `button` `type`.

## Icon and positioning

Use the `icon` prop to add an icon to the button. Never pass the icon as a child: that is an anti-pattern (see [Common mistakes](#common-mistakes)). By default, it will render on the logical start side of the button. Use the `iconPlacement` prop to change the side the icon is rendered on.

A button with an icon tightens the padding on the icon's side by `0.125rem`, so the glyph reads optically aligned with the text block. That arrives as a `ps-*` or `pe-*` longhand, which a `px-*` override cannot beat: both survive the class merge, and Tailwind emits the longhand last. Override padding on a button with an icon in longhands too: `className="ps-1.5 pe-1"`, not `className="px-1.5"`.

```tsx
import { Button } from "@ngrok/mantle/button";
import { FireIcon } from "@phosphor-icons/react/Fire";

<Button appearance="outlined" intent="neutral" icon={<FireIcon weight="fill" />}>
	Icon Start
</Button>
<Button appearance="outlined" intent="neutral" icon={<FireIcon weight="fill" />} iconPlacement="end">
	Icon End
</Button>
```

## Common mistakes

An icon-only button is not a `Button`: use [`IconButton`](/components/actions/icon-button) instead. `Button`'s `icon` prop places an icon *beside* its text children; render it with no children and the button has no accessible name (screen readers announce nothing) and keeps its text-box horizontal padding, so it draws as an off-square lozenge instead of a square. `IconButton` requires an accessible `label`, rendered as its `aria-label`, and renders a square box on the same shared size scale.

```tsx
import { Button } from "@ngrok/mantle/button";
import { CopyIcon } from "@phosphor-icons/react/Copy";

// ❌ Wrong — icon-only Button: no accessible name, text-shaped padding
<Button appearance="outlined" intent="neutral" icon={<CopyIcon />} onClick={copyPage} />;
```

```tsx
import { IconButton } from "@ngrok/mantle/button";
import { CopyIcon } from "@phosphor-icons/react/Copy";

// ✅ Correct — IconButton: required screen-reader label, square box
<IconButton
	appearance="outlined"
	intent="neutral"
	icon={<CopyIcon />}
	label="Copy page"
	onClick={copyPage}
/>;
```

Never pass an icon as a child. A child icon is an anti-pattern: use the `icon` prop. The `icon` slot sizes the icon, holds it at `shrink-0`, tightens the padding on its side so it reads as optically aligned, and hands its place to the spinner when `isLoading` turns on. A child icon gets none of that.

The one exception is a **second** icon, such as a trailing caret, because the button has one `icon` slot. See [Browser translation](/browser-translation#buttons-with-an-icon-or-two) for the shape that stays safe on a translated page.

```tsx
import { Button } from "@ngrok/mantle/button";
import { PlusIcon } from "@phosphor-icons/react/Plus";

// ❌ Wrong — the icon keeps its own size, and `isLoading` renders a second glyph beside it
<Button appearance="filled" intent="neutral">
	<PlusIcon />
	Create endpoint
</Button>;
```

```tsx
import { Button } from "@ngrok/mantle/button";
import { PlusIcon } from "@phosphor-icons/react/Plus";

// ✅ Correct — `icon` gets its own slot beside the label
<Button appearance="filled" intent="neutral" icon={<PlusIcon />}>
	Create endpoint
</Button>;
```

## isLoading

`isLoading` sets whether the button is in a loading state, default `false`. Setting `isLoading` replaces any `icon` with a spinner, or adds one when the button has no icon. It also disables user interaction with the button and sets `aria-disabled`.

```tsx
import { Button } from "@ngrok/mantle/button";
import { FireIcon } from "@phosphor-icons/react/Fire";

<Button appearance="outlined" intent="neutral">
	No Icon + Idle
</Button>
<Button appearance="outlined" intent="neutral" icon={<FireIcon weight="fill" />}>
	Icon Start + Idle
</Button>
<Button appearance="outlined" intent="neutral" icon={<FireIcon weight="fill" />} iconPlacement="end">
	Icon End + Idle
</Button>
<Button appearance="outlined" intent="neutral" isLoading>
	No Icon + isLoading
</Button>
<Button appearance="outlined" intent="neutral" icon={<FireIcon weight="fill" />} isLoading>
	Icon Start + isLoading
</Button>
<Button
	appearance="outlined"
	intent="neutral"
	icon={<FireIcon weight="fill" />}
	iconPlacement="end"
	isLoading
>
	Icon End + isLoading
</Button>
```

## The label slot

`children` render inside a `<span data-slot="button-label">`, beside the `icon` slot. The span exists so an appearing icon or spinner inserts before an element sibling: a browser translation engine reparents text nodes, and an insert aimed at one throws. [Browser translation](/browser-translation) covers the mechanism and the rule your own components need.

The span carries `display: contents`, so it generates no box: every child stays a flex item of the button, and the button's `gap` still falls between them. Two children therefore lay out exactly as they did before the span existed.

```tsx
<Button appearance="outlined" intent="neutral" icon={<PlusIcon />}>
	<span>Create endpoint</span>
	<CaretDownIcon />
</Button>
```

Style the label by styling your own child, not the slot. A label that has to shrink inside a width-constrained button takes `min-w-0 flex-1 truncate`; those work because the child is a flex item of the button.

```tsx
<Button
	appearance="outlined"
	intent="neutral"
	className="max-w-48 min-w-0 justify-start"
	icon={<PlusIcon />}
>
	<span className="min-w-0 flex-1 truncate text-left">
		A label far too long for the width of this button
	</span>
	<CaretDownIcon />
</Button>
```

## Button group

`ButtonGroup` is a contained group of related buttons. The `"panel"` appearance draws a bordered panel and sets `--icon-button-border-radius` to `0.125rem`, so nested icon buttons square up to fit the chrome. The `"ghost"` appearance only spaces the buttons.

```tsx
import { ButtonGroup, IconButton } from "@ngrok/mantle/button";
import { TextBIcon } from "@phosphor-icons/react/TextB";
import { TextItalicIcon } from "@phosphor-icons/react/TextItalic";
import { TextUnderlineIcon } from "@phosphor-icons/react/TextUnderline";

<ButtonGroup appearance="panel">
	<IconButton appearance="ghost" intent="neutral" icon={<TextBIcon />} label="Bold" />
	<IconButton appearance="ghost" intent="neutral" icon={<TextItalicIcon />} label="Italic" />
	<IconButton appearance="ghost" intent="neutral" icon={<TextUnderlineIcon />} label="Underline" />
</ButtonGroup>;
```

## Polymorphism

To render *something else* as a `Button`, use the `asChild` prop to compose. This is useful when you want to splat the `Button` styling onto a `react-router` `Link`. When you use `asChild`, the `Button` does **NOT** pass its `type` prop to the child component.

A disabled or loading `Button` under `asChild` gets no `disabled` attribute, because the attribute is inert on an `<a>`. The child gets `aria-disabled="true"`, leaves the tab order with `tabIndex={-1}`, and a click on it is cancelled before any handler or router navigation runs.

```tsx
import { Button } from "@ngrok/mantle/button";
import { FireIcon } from "@phosphor-icons/react/Fire";
import { Link, href } from "react-router";

<Button appearance="filled" intent="neutral" icon={<FireIcon weight="fill" />} asChild>
	<Link to={href("/base/colors")}>See our colors!</Link>
</Button>;
```

## API Reference

### Button

All props from [button](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attributes), plus:

| Prop             | Type                                                | Default    | Description                                                                                                                                                                                                                                                                                                                                                                                                |
| ---------------- | --------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `appearance`     | `"ghost"` \| `"filled"` \| `"outlined"` \| `"link"` |            | The visual style of the `Button`. **Required** — there is no default, so every call site states the visual weight it means.                                                                                                                                                                                                                                                                                |
| `asChild?`       | `boolean`                                           | `false`    | Use the `asChild` prop to compose the `Button` styling onto alternative element types or your own React components.                                                                                                                                                                                                                                                                                        |
| `icon?`          | `ReactNode`                                         |            | An icon to render inside the button. When `isLoading` is `true`, the icon will automatically be replaced with a spinner.                                                                                                                                                                                                                                                                                   |
| `iconPlacement?` | `"start"` \| `"end"`                                | `"start"`  | The side that the icon will render on, if one is present. When `isLoading` is `true`, the loading spinner will also render on this side.                                                                                                                                                                                                                                                                   |
| `intent`         | `"accent"` \| `"danger"` \| `"neutral"`             |            | The tone of the `Button` — the purpose its color communicates to the user: `"neutral"` for the workhorse tone (routine and secondary actions, and the default primary action: `appearance="filled" intent="neutral"`), `"accent"` for deliberate brand emphasis, and `"danger"` for a destructive or irreversible action. **Required** — there is no default, so every call site states the tone it means. |
| `isLoading?`     | `boolean`                                           | `false`    | Whether the button is in a loading state. Setting `isLoading` replaces any `icon` with a spinner, or adds one when the button has no icon. It also disables user interaction with the button and sets `aria-disabled`.                                                                                                                                                                                     |
| `size?`          | `"xs"` \| `"sm"` \| `"md"` \| `"lg"` \| `"xl"`      | `"md"`     | The size of the `Button`, controlling its box height and horizontal padding. Shared scale with `IconButton` — the same size name renders the same box height. Has no effect when `appearance="link"`.                                                                                                                                                                                                      |
| `type?`          | `"button"` \| `"reset"` \| `"submit"`               | `"button"` | The behavior of the `Button` when activated. Defaults to `"button"`, so a `Button` inside a `<form>` will **not** submit it — pass `type="submit"` to submit the form. When `asChild` is used, `type` has no effect and is not forwarded to the child. See [the MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#type) for more information.                                     |

#### Data attributes

| Data Attribute    | Value                                               | Description                                                                                                                                                                                                                                                                                  |
| ----------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data-slot`       | `"button"`                                          | On the button element. Stable styling hook that survives a `className` override or an `asChild` swap.                                                                                                                                                                                        |
| `data-slot`       | `"button-label"`                                    | On the `<span>` wrapping `children`, beside the `icon` slot. `display: contents`, so every child stays a flex item of the button and the `gap` still falls between them. Style a label by styling your own child, not this slot.                                                             |
| `data-appearance` | `"filled"` \| `"ghost"` \| `"link"` \| `"outlined"` | The `appearance` prop.                                                                                                                                                                                                                                                                       |
| `data-intent`     | `"accent"` \| `"danger"` \| `"neutral"`             | The `intent` prop.                                                                                                                                                                                                                                                                           |
| `data-size`       | `"xs"` \| `"sm"` \| `"md"` \| `"lg"` \| `"xl"`      | The `size` prop. Omitted when `appearance="link"`, which has no box to size.                                                                                                                                                                                                                 |
| `data-loading`    | present when `isLoading`                            | Presence-only, never `"false"`.                                                                                                                                                                                                                                                              |
| `data-disabled`   | present when disabled                               | Presence-only, never `"false"`, since the `data-disabled:` variant matches the attribute's existence. Set when the button resolves disabled: `disabled`, `aria-disabled="true"`, or `isLoading` with no explicit `disabled`. `disabled={false}` keeps a loading button enabled and omits it. |

### ButtonGroup

A contained group of related buttons.

All props from [div](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/div#attributes), plus:

| Prop          | Type                                   | Default | Description                                                                                                                                                                                  |
| ------------- | -------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `appearance?` | `"panel"` \| `"ghost"` \| `"outlined"` |         | The visual style of the group. `"panel"` draws a bordered panel and tightens `--icon-button-border-radius` to `0.125rem`; `"ghost"` only spaces the buttons; `"outlined"` draws nothing yet. |
| `asChild?`    | `boolean`                              | `false` | Use the `asChild` prop to compose the `ButtonGroup` styling onto alternative element types or your own React components.                                                                     |

#### Data attributes

| Data Attribute | Value            | Description                                                                                          |
| -------------- | ---------------- | ---------------------------------------------------------------------------------------------------- |
| `data-slot`    | `"button-group"` | On the group element. Stable styling hook that survives a `className` override or an `asChild` swap. |
