GrButton
Triggers the primary action in a form, dialog or panel.
Overview
When to take it
- the action changes state — save, delete, submit: the address does not change;
- the action goes to a server —
loadingblocks a repeat and announces the wait; - the weight of the action is visible —
variantandtoneseparate the primary, the secondary and the dangerous; - the button is an icon only —
squaretogether withariaLabel: without a name such a button is nameless; - a link that looks like a button is needed —
href/asswap the tag while keeping the semantics of a link.
When to take something else
| Need | Take |
|---|---|
| The step changes the address | GrLink |
| There are several actions in a row | GrButtonGroup |
| There are many actions and they are hidden | GrDropdownMenu |
| An on/off state is toggled | GrSwitch |
| The view of one piece of content is switched | GrSegmented |
Variants every tone and view, rendered by the real component
tone \ variant | primary | secondary | outline | ghost | ghost-border |
|---|---|---|---|---|---|
primary | |||||
neutral | |||||
success | |||||
warning | |||||
danger | |||||
info | |||||
slate | |||||
azure |
Sizes
xssmmdlg
States
normaldisabledloading
The grid is live: hover and press it. Hover, focus and active states are not drawn here — they belong to the browser, not to a prop.
Playground controls come from the API, not from hand
Loading…
0 changed· state is in the URL
<GrButton>Save</GrButton>Examples 4 each with a live demo and its code
A toolbar with one loud action
Secondary tools are
variant="ghost-border": quiet in a row, outlined under the pointer. Theprefixandsuffixslots hold the icon, the filter count and the shortcut, so the label never collides with them. Asquarebutton with an icon alone gets its name fromaria-label; the one action that creates something is filled.InvoicesNorthwind TradersINV-2026-0418 · Overdue€5,166.50Contoso LtdINV-2026-0417 · Paid€820.00Tailspin ToysINV-2026-0416 · Sent€310.00Save and discard at the foot of a form
type="submit"andtype="reset"let the form do the work: Enter saves, and the reset is taken over to restore what is saved. While the request runs,loadingkeeps the button focusable and blocks a second click, andloading-texttells the screen reader what is being saved. Both buttons wait for a change — there is nothing to save or discard yet.A destructive action one deliberate step away
tone="danger"marks what cannot be undone, and the outlined trigger only opens the step. The final button staysdisableduntil the name is typed, takes the full width withblock, and showsloadingwhile the workspace goes. Cancel returns the keyboard to the trigger through the exposedfocus().Danger zone Transfer ownershipHand the workspace to another admin.Delete workspaceInvoices, customers and 12 members lose access.Buttons that are links
Every step goes somewhere, so every button is a link that looks like one.
astakes the router’s link for an in-app route,hrefrenders a plain<a>, a namedtargetwithrelreuses one docs tab, andexternalopens a new tab with a saferelby itself. Adisabledlink leaves the Tab order and keeps the muted look — links have no nativedisabled.Launch checklistEvery step goes somewhere, so every button here is a link. - 1. Invite your teamInvite people
- 2. Read the getting-started guideOpen the guide
- 3. Look through the sourceGitHub(opens in a new tab)
- 4. Connect a payment providerOnly an owner can do this.Connect
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
disabled | boolean | undefined | false | The action is unavailable: native `disabled` on a `<button>`; on a link button — `aria-disabled` and the address removed. The colours come from tokens, without opacity. |
type | "button" | "submit" | "reset" | undefined | "button" | The native `type` of the `<button>`. `button` by default: a button inside a form does not submit it on its own. |
tone |
| undefined | The meaning of the action: the colour of the fill, the border and the text. `danger` is for the irreversible, `neutral` for the secondary. `primary` by default; inside `GrButtonGroup` — the tone of the group. |
variant | GrButtonVariant | undefined | undefined | The weight of the button: `primary` — a fill in the tone, `secondary` — a soft background with a border, `outline` — a border only, `ghost` — text only, `ghost-border` — text whose border appears under the pointer. `primary` by default; globally — `componentDefaults`. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | The height and type size on the control scale. `md` by default; taken from the group and `GrConfigProvider`. |
ariaLabel | string | undefined | undefined | The accessible name of the button when there is no text: a `square` with a single icon is nameless without it. |
loading | boolean | undefined | false | The action is in progress: a spinner in place of the prefix, or over the label when there is none, with the width unchanged; `aria-busy` and the click intercepted. The button keeps the focus meanwhile — native `disabled` is not set. |
loadingText | string | undefined | undefined | i18n: what exactly is loading. `aria-busy` on its own is not announced by some AT. |
square | boolean | undefined | undefined | Equal sides — a button made of a single icon. Without text it needs `ariaLabel`. |
as | string | Component | undefined | undefined | Polymorphism: a root tag or component of your own (a RouterLink, for instance). |
loadingIndicator | GrButtonLoadingIndicator | undefined | undefined | The loading look. `spinner` is a spinner; `shimmer` — light runs over the label while the label, icons and width stay in place. Without a text label (an icon-only button) there is nothing to shimmer — a spinner is used there. `track` — a segment runs along the bottom edge, the content stays in place. Defaults to `spinner`; a theme changes it through `componentDefaults` (Clay — `shimmer`, Blueprint — `track`). |
prefixCell | boolean | undefined | undefined | The prefix in a square cell as tall as the button, behind a divider. Only with a label: for a square button the cell is the button itself. Defaults to `false`; a theme turns it on through `componentDefaults` (Blueprint). |
block | boolean | undefined | false | A button across the full width of the container. |
href | string | undefined | undefined | Renders the button as an `<a href>` (unless `as` is set). |
target | string | undefined | undefined | The `target` of a link button. With `_blank`, `rel="noopener noreferrer"` is added automatically. |
rel | string | undefined | undefined | The `rel` of a link button. Without it, `noopener noreferrer` is set when `target="_blank"`. |
external | boolean | undefined | false | Open the link in a new tab: `target="_blank"` and `rel="noopener noreferrer"`. Leaving is announced in advance — by an icon on the right and by hidden text in the name. |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | The content of the button. |
prefix | any | An addon on the left: an icon, a counter. In the loading state it gives its place to the spinner. |
suffix | any | An addon on the right: a chevron, a counter. |
Methods
| Methods | Signature | Description |
|---|---|---|
focus | () => void | Put focus on the button — both when the root is a tag and when it is a component from `as`. |
blur | () => void | Remove focus from the button. |
Types
Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.
import type { GrButtonConfigurableProps, GrButtonVariant, GrButtonLoadingIndicator } from '@feugene/granularity/components/GrButton'Filedist/types/components/GrButton/config.d.ts
const grButtonConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrButton">;Filedist/types/components/GrButton/defaults.d.ts
interface GrButtonConfigurableProps {
variant: GrButtonVariant;
tone: GrButtonTone;
size: GrButtonSize;
square: boolean;
loadingIndicator: GrButtonLoadingIndicator;
prefixCell: boolean;
}Filedist/types/components/GrButton/grButtonStyles.d.ts
type GrButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'ghost-border';type GrButtonLoadingIndicator = 'spinner' | 'shimmer' | 'track';type GrButtonTone = GrTone;type GrButtonSize = GrComponentSize;Filedist/types/components/GrButton/safelist.d.ts
const grButtonSafelist: string[];Filedist/types/components/GrButton/GrButton.vue.d.ts
interface GrButtonProps {
variant?: GrButtonVariant;
tone?: GrButtonTone;
size?: GrButtonSize;
loading?: boolean;
loadingIndicator?: GrButtonLoadingIndicator;
prefixCell?: boolean;
loadingText?: string;
disabled?: boolean;
square?: boolean;
block?: boolean;
type?: 'button' | 'submit' | 'reset';
ariaLabel?: string;
as?: string | Component;
href?: string;
target?: string;
rel?: string;
external?: boolean;
}Filedist/types/components/GrButton/index.d.ts
type GrButtonInstance = ComponentExposed<typeof GrButtonComponent>;Usage from the library prose
disabled and loading are different states
disabled sets the native attribute (on a <button>) and dims the button with
the --gr-button-disabled-bg / -fg / -brd tokens. There is no transparency
here on purpose: opacity dilutes colours tuned to AA, and a button stands on a
coloured background more often than other components, where diluted text is the
first to fail.
Those tokens are themselves references to the shared roles
--gr-disabled-bg/-fg/-brd, the same ones a tab, a segment and a slider use: the
disabled state looks the same across the whole package and is recoloured from one
place. The per-component trio remains a point of customisation for the case where
a button has to differ.
The dimming works for a button-link too (href/as): the native disabled does
not reach it, so the colours are set with classes rather than with the disabled:
variant.
A button that has to stay in the tab order but is declared unavailable is marked
with the aria-disabled="true" attribute. It gets the same dimmed look, the
native disabled is not set, and the component does not intercept the click —
the consumer suppresses it. This is how GrTransfer keeps a move that has
nowhere to go: dropping focus from the pressed button into the document body
would be worse.
loading does not set the native disabled — the element would be left
without focus, and the screen reader would lose the context. Instead there are
aria-busy, aria-disabled and click interception in the capture phase. The
button looks ordinary while that happens: the state is told by the spinner. The
cursor is progress, not not-allowed: the button is busy, not forbidden. Neither
a waiting nor a disabled button sinks on press — they do not accept the press and
do not pretend to.
Loading does not change the width
With a prefix, the spinner takes its place. Without a prefix it has nowhere to go, so it lies on top of the content and the content fades out: the neighbouring buttons in a row do not shift, as they would from a spinner added on the left.
It fades through transparency rather than visibility — otherwise the label would
drop out of the accessible name. A bare text node cannot be reached by a selector,
so the text is faded with -webkit-text-fill-color: color stays as it was, and
the spinner is drawn in the colour of the label.
<GrButton :loading="saving" loading-text="Saving the report">
Save
</GrButton>aria-busy on its own is not announced by every AT, so during loading a hidden
suffix is added to the name of the button — loadingText or the gr.button.loading
key.
Light over the label instead of a spinner
loadingIndicator="shimmer" is another signal of the same loading: there is no spinner, light
runs over the glyphs of the label, and the label, the addon icons and the width stay in place.
aria-busy, the hidden suffix and the click interception are the same as with the spinner.
<GrButton tone="neutral" loading-indicator="shimmer" :loading="saving">
Save the draft
</GrButton>- The gradient is on the label, not on the button.
background-clip: texton the root would cut off its fill, so the light is laid on the text wrapper;coloris not touched, and the icons drawn with it do not shimmer. - Only with a text label. A button made of a single icon has nothing to shimmer — the spinner stays there, otherwise the loading would not be visible.
prefers-reduced-motion. There is no shimmer, the label is dimmed — this kind of loading has no other signal besides it.
The kind of loading is styling, so it is set both by a theme (componentDefaults in
theme.json: that is how Clay turns it on) and by GrConfigProvider. The duration of a pass
is the --gr-button-shimmer-duration theme token.
A bar along the edge instead of a spinner
loadingIndicator="track" shows loading as a bar running along the bottom edge on top of the border:
the label, the icons and the width stay in place, the label is slightly dimmed. Every
button has an edge, so a square one runs a bar too rather than a spinner. aria-busy, the hidden
suffix and the click interception are the same.
With prefers-reduced-motion the bar is not switched off but slowed down: loading has no
other signal. The period is the --gr-button-track-duration theme token; the kind is turned on by a theme
(Blueprint).
The icon cell
prefixCell puts the prefix into a square cell as tall as the button, behind a divider at
24 % of the label colour, and removes the horizontal padding on its side. Only a button with a
label has the cell: for a square button the cell is the button itself. The loading spinner takes the cell
rather than standing next to it. A theme turns it on through componentDefaults (Blueprint).
Slots
<GrButton>
<template #prefix><GrIcon><IconPlus /></GrIcon></template>
Add
<template #suffix><GrKbd keys="mod+N" /></template>
</GrButton>During loading the spinner takes the place of the prefix and #prefix is not
rendered: two icons side by side read as a layout defect. With the shimmer and the bar the prefix stays
in place — there is no spinner there.
On the side of a non-empty add-on the horizontal padding is 2px smaller — the glyph
sits optically centred rather than pressed to the edge. An empty slot (v-if
inside the template) leaves the padding alone.
An icon in a slot takes its step from the button: xs → 14px, sm and md → 16px,
lg → 20px. The button hints it to GrIcon through context; an explicit size on
the icon beats the hint, and the hint beats GrConfigProvider. The gap between the
parts grows the same way: 4 / 6 / 8 / 8px.
A long label is truncated
A label made of text alone is wrapped by the button and cut with an ellipsis, and the full text is shown as a native tooltip — only when the line is really cut. The accessible name stays whole. An icon or markup in the default slot is not wrapped: the wrapper would shift its alignment, and a square button would stop being a square.
Focus is an outline with an offset
Focus is drawn as a 2px outline in --gr-ring, on :focus-visible only;
the offset is the --gr-button-focus-offset theme token (2px in Slate). The outline can
fly in from a distance: --gr-button-focus-offset-from is the offset at the moment it appears (Tactile —
6px). A ring right against a fill of the same tone read as a
thicker button — indigo on indigo gives 1.4:1 — and box-shadow is switched off
entirely in forced-colors. outline stays there and is recoloured to a system
colour.
The weights do not compete with the main one
In Slate the secondary border at rest is 38 % of the tone, the outline one 70 %; the border
gets the full tone under the pointer. A saturated border on a minor weight pulled
the eye away from primary. A neutral secondary is the “ordinary” form button: the
card surface (--gr-button-neutral-soft-bg), the --gr-button-neutral-border
border and the --gr-button-surface-shadow shadow.
A solid button has volume: the theme shadow (--gr-button-shadow), beneath it a shadow in the colour of the tone
(--gr-button-glow with the --gr-button-<tone>-glow colour) and a layer over the fill
(--gr-button-solid-image); on press the shadow goes inside (--gr-button-shadow-active).
In Slate this is a highlight along the top edge and a small shadow, with no tone shadow and no layer. A disabled
button loses the volume together with the variant. Every weight has a 1px border — transparent
for ghost — so a ghost button is as wide as its neighbours in a row.
The label uses the --gr-button-font-weight theme token: medium in Slate, 600 in Blueprint.
Hover and press of a soft fill mix the tone into the fill itself rather than into the background. That is why they darken in the light mode and lighten in the dark one — following the resting state.
The look belongs to the theme, the mechanics to the button
The shape, the response and the volume are tokens of the theme kind: the theme sets their values, the button only
reads them. Hence four different buttons on one API — Slate, Tactile, Blueprint and
Clay; the breakdown is in ../design/GrButton.md.
- The radius is per step:
--gr-button-radius-{xs,sm,md,lg}. One radius for every step is the application handle--gr-button-radius(999pxis a capsule); the theme does not set it. - The response under the pointer is a
::beforelayer beneath the content:::afteris taken by the touch target. Its position, size, colour and growth are set by--gr-button-response-*; the colour is computed fromcurrentColor, so one token suits every tone. In Tactile it is the backing of the transparent weights, in Blueprint a travel line along the bottom edge, in Slate the layer is transparent. - Lift and tempo —
--gr-button-lift,--gr-button-motion-durationand-ease; withprefers-reduced-motionthere is no movement, only the colour change remains. - An unavailable button takes the border style and the layer over the fill from the theme
(
--gr-button-disabled-border-style,-image): a dashed border and hatching read without colour.
A pressed toggle keeps the pressed look
A button with aria-pressed="true" — a toggle that is on — keeps the look of the pressed
state of its weight (the data-gr-pressed attribute): otherwise “on” and “off”
would differ only for the screen reader. In a GrButtonGroup tray the selected segment is a sheet
on top of the tray, and there is no attribute. aria-current does not change the look: on a link it is navigation, and
its look is decided by the layout, not by the button.
The touch target on a touch screen
On a coarse pointer (pointer: coarse) a button lower than --gr-button-touch-size
(44px) makes up the height with an invisible ::after. The look and the density
of a row stay the same: an xs in a table row remains 28px, and there are no
missed taps.
A square button and block
square gives a button with equal sides — the size comes from
--gr-button-square-size with a default per size (1.75rem / 2rem / 2.5rem /
2.75rem). The variable can be set in your own CSS to fit the button to your grid:
.toolbar { --gr-button-square-size: 2.25rem; }The variable is one for all four steps. Set higher up the tree — on :root, in
a theme — it collapses xs, sm, md and lg into a single number, and the step
stops meaning anything. Its place is therefore a narrow scope like the example
above, not the root of the document. If you need a size that depends on the input
device, set it through GrConfigProvider rather than through the variable: the
application knows about its pointer, the package does not.
block stretches the button to the width of the container.
The polymorphic root
as → <a href> → <button>. A link automatically gets
rel="noopener noreferrer" if it opens in a new tab (external or an explicit
target="_blank"), and disabled removes it from the tab order.
Leaving for a new tab is announced in advance, as with GrLink (WCAG 3.2.5): an
icon appears on the right, and a hidden “(opens in a new tab)” is added to the
name, key gr.button.opensInNewTab. With ariaLabel set, the hint is appended to
the name itself, otherwise aria-label would override it. A square button has no
icon — there is no room for it — but the hint stays.
Props in this example