Installation
npx gbs-add-block@latest -a Button -betaThe 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/react19 - TypeScript target ES2022 or newer, with
"jsx": "react-jsx" - Browsers from 2024 or newer (the styles use
light-dark()andcolor-mix())
Import the stylesheet once, for example in your global CSS:
@import "../components/button/styles.css";or in your root layout / entry file:
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.
Default
Quick Start
"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.
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. |
className | string | — | Class for the button. |
classNames | Partial<Record<ButtonSlot, string>> | — | Slots: root, content, spinner, icon. |
localeText | Partial<ButtonLocaleText> | English | See Locale Text. |
ref | Ref<HTMLButtonElement> | — | The <button> element. |
Variants
<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
<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} |
<form action={createInvoice}>
<Input name="customer" label="Customer" required />
<Button type="submit">Create invoice</Button>
</form>- Stays focusable: while busy, the button uses
aria-disabledinstead ofdisabled, so keyboard focus doesn't jump elsewhere. - Stays put: the label is hidden but keeps its space, so the button doesn't change width.
loadingTextswaps 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:
import Link from "next/link";
<Button variant="outline" render={(props) => <Link href="/billing" {...props} />}>
Go to billing
</Button>;<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.
<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. |
/* 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
<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
onClickorrender: these are functions, which a Server Component can't pass to a client component. Use them inside a client component, for example to wrapnext/linkwithrender.
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. rendermust spread the props it receives onto the element, or the look and click handling are lost.