Skip to main content

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.

Neutral

Accent

Danger

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 and SplitButton: 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.

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.

// ✅ 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). 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".

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

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} />;
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 for the shape that stays safe on a translated page.

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

Idle

isLoading

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

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

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

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.

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, plus:

PropTypeDefaultDescription
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?booleanfalseUse the asChild prop to compose the Button styling onto alternative element types or your own React components.
icon?ReactNodeAn 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?booleanfalseWhether 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 for more information.

Data attributes

Data AttributeValueDescription
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-loadingpresent when isLoadingPresence-only, never "false".
data-disabledpresent when disabledPresence-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, plus:

PropTypeDefaultDescription
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?booleanfalseUse the asChild prop to compose the ButtonGroup styling onto alternative element types or your own React components.

Data attributes

Data AttributeValueDescription
data-slot"button-group"On the group element. Stable styling hook that survives a className override or an asChild swap.