Skip to content
Beta · ExperimentalReact 19No peer dependencies

Button

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

Beta components are subject to change and may break your code. Use them at your own risk, and share feedback through the bug tracker.

On this page

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.

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

Live preview

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.

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:

PropTypeDefaultDescription
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.
loadingbooleanfalseBusy state: spinner, aria-busy, clicks ignored.
loadingTextReactNodeText next to the spinner while busy, in place of the label.
leadingReactNodeIcon before the label.
trailingReactNodeIcon after the label.
iconReactNodeMakes a square icon-only button. Always add aria-label.
fullWidthbooleanfalseFill the container's width.
disabledbooleanfalseNatively disabled.
onClick(event) => unknownReturn a promise to show loading until it settles.
render(props: ButtonRenderProps) => ReactElementRender another element with the button's look. See Links.
classNamestringClass for the button.
classNamesPartial<Record<ButtonSlot, string>>Slots: root, content, spinner, icon.
localeTextPartial<ButtonLocaleText>EnglishSee Locale Text.
refRef<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:

SourceHow
Async onClickReturn a promise: onClick={async () => { await save(); }}
Form submissionA type="submit" button inside <form action={serverAction}> is busy while the action runs (React 19 useFormStatus).
Manualloading={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.

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

KeysAction
Enter / SpaceActivate the button.
TabMove 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.

VariableUsed for
--bt-height, --bt-px, --bt-gap, --bt-font-sizeSize (set by size).
--bt-fill, --bt-fill-hover, --bt-ink, --bt-lineBackground, hover background, text and border (set by variant).
--bt-accent, --bt-accent-fgThe primary variant and link text.
--bt-danger, --bt-danger-fgThe danger variant.
--bt-subtleThe secondary background.
--bt-border, --bt-hoveroutline border and hover background.
--bt-focusFocus ring.
--bt-radiusCorner 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

ElementAttributes
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" }} />
KeyDefault
loading"Loading" — announced while busy, unless loadingText is given

Headless Use

ExportDescription
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

PreviousNew
CustomBtnButton
value="Submit" (the label)children: <Button>Submit</Button>
id, name, className, disabled, onClickSame 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.