GrButton
Triggers the primary action in a form, dialog or panel.
Machine-translated from the Russian original, not yet reviewed. Read the original
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 |
`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.
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.
<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.
Slots
<GrButton>
<template #prefix><GrIcon><IconPlus /></GrIcon></template>
Add
<template #suffix><GrKbd keys="mod+N" /></template>
</GrButton>
The icon and the text used to fall into one slot, and the order was held by the
discipline of the consumer. 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.
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.
Playground 13
Loading…
<GrButton />Install
npm i @feugene/granularityImport
import { GrButton } from '@feugene/granularity/components/GrButton'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
tone | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined | undefined | — |
variant | GrButtonVariant | undefined | undefined | — |
type | "button" | "submit" | "reset" | undefined | "button" | — |
disabled | boolean | undefined | false | — |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
ariaLabel | string | undefined | undefined | — |
loading | boolean | undefined | false | — |
loadingText | string | undefined | undefined | i18n: what exactly is loading. `aria-busy` on its own is not announced by some AT. |
square | boolean | undefined | undefined | — |
as | string | Component | undefined | undefined | Polymorphism: a root tag or component of your own (a RouterLink, for instance). |
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 | — |
rel | string | undefined | undefined | — |
external | boolean | undefined | false | — |
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 / Expose
| Methods / Expose | Type | Description |
|---|---|---|
focus | () => void | — |
blur | () => void | — |
Examples 3
Slots
<script setup lang="ts">
import { ref } from 'vue'
import { GrButton, GrIcon, GrKbd } from '@feugene/granularity'
import IconPlus from '~icons/lucide/plus'
const saving = ref(false)
async function save(): Promise<void> {
saving.value = true
await new Promise(resolve => setTimeout(resolve, 1200))
saving.value = false
}
</script>
<template>
<div class="grid gap-4">
<div class="flex flex-wrap items-center gap-3">
<!-- Иконка и текст больше не валятся в один слот. -->
<GrButton>
<template #prefix>
<GrIcon size="sm" aria-hidden="true">
<IconPlus />
</GrIcon>
</template>
Добавить проект
<template #suffix>
<GrKbd keys="mod+N" size="xs" />
</template>
</GrButton>
<!-- Во время загрузки спиннер занимает место префикса. -->
<GrButton :loading="saving" loading-text="Сохраняем отчёт" @click="save">
<template #prefix>
<GrIcon size="sm" aria-hidden="true">
<IconPlus />
</GrIcon>
</template>
Сохранить
</GrButton>
<GrButton disabled>
Отключённая кнопка
</GrButton>
<GrButton href="https://example.com" disabled>
Отключённая ссылка
</GrButton>
</div>
<GrButton block variant="outline">
Кнопка на всю ширину
</GrButton>
<div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
Отключённые кнопка и ссылка гасятся одной парой токенов — раньше ссылке не доставалось ничего,
потому что нативный `disabled` к ней неприменим. Во время загрузки к имени кнопки добавляется
скрытый текст: `aria-busy` сам по себе объявляет не всякий скринридер.
</div>
</div>
</template>Interactive button constructor
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
GrButton,
GrFormField,
GrInput,
GrRadioGroup,
GrSelect,
GrSwitch,
GrCard,
type GrButtonSize,
type GrButtonTone,
type GrButtonVariant,
} from '@feugene/granularity'
import IconSparkles from '~icons/lucide/sparkles'
import CodeBlock from '../../../components/doc/CodeBlock.vue'
type GrButtonType = 'button' | 'submit' | 'reset'
const variant = ref<GrButtonVariant>('primary')
const tone = ref<GrButtonTone>('primary')
const size = ref<GrButtonSize>('md')
const type = ref<GrButtonType>('button')
const label = ref('Create workspace')
const ariaLabel = ref('Create workspace')
const loading = ref(false)
const disabled = ref(false)
const square = ref(false)
const variantOptions = [
{ value: 'primary', label: 'Primary' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'outline', label: 'Outline' },
{ value: 'ghost', label: 'Ghost' },
{ value: 'ghost-border', label: 'Ghost border' },
] satisfies Array<{ value: GrButtonVariant, label: string }>
const toneOptions = [
{ value: 'primary', label: 'Primary' },
{ value: 'neutral', label: 'Neutral' },
{ value: 'success', label: 'Success' },
{ value: 'warning', label: 'Warning' },
{ value: 'danger', label: 'Danger' },
{ value: 'info', label: 'Info' },
{ value: 'slate', label: 'Slate' },
{ value: 'azure', label: 'Azure' },
] satisfies Array<{ value: GrButtonTone, label: string }>
const sizeOptions = [
{ value: 'xs', label: 'XS' },
{ value: 'sm', label: 'SM' },
{ value: 'md', label: 'MD' },
{ value: 'lg', label: 'LG' },
] satisfies Array<{ value: GrButtonSize, label: string }>
const typeOptions = [
{ value: 'button', label: 'button' },
{ value: 'submit', label: 'submit' },
{ value: 'reset', label: 'reset' },
] satisfies Array<{ value: GrButtonType, label: string }>
const buttonText = computed(() => {
if (loading.value && !square.value)
return 'Saving…'
return label.value.trim() || 'Create workspace'
})
const effectiveAriaLabel = computed(() => {
return ariaLabel.value.trim() || buttonText.value
})
const previewSummary = computed(() => {
if (square.value)
return 'Square mode makes the button icon-only, so `aria-label` should describe the action for screen readers.'
if (loading.value)
return 'Loading automatically disables the button and helps prevent repeated submit actions in async scenarios.'
if (disabled.value)
return 'Disabled preserves the visual contract of the selected variant/tone while turning off interactivity and pointer events.'
if (variant.value === 'ghost' || variant.value === 'ghost-border')
return 'Ghost variants work best in toolbars and dense action areas where a filled CTA would feel too heavy.'
return 'Combine `variant`, `tone`, `size`, and `type` to quickly verify the button contract before shipping it to a product scenario.'
})
function escapeAttribute(value: string) {
return value.replaceAll('&', '&').replaceAll('"', '"')
}
const previewCode = computed(() => {
const attributes = [
`variant="${variant.value}"`,
`tone="${tone.value}"`,
`size="${size.value}"`,
`type="${type.value}"`,
]
if (loading.value)
attributes.push('loading')
if (disabled.value)
attributes.push('disabled')
if (square.value)
attributes.push('square')
if (square.value || effectiveAriaLabel.value !== buttonText.value)
attributes.push(`aria-label="${escapeAttribute(effectiveAriaLabel.value)}"`)
const content = square.value && !loading.value
? ' <IconSparkles class="h-4 w-4" aria-hidden="true" />'
: ` ${buttonText.value}`
return ['<GrButton', ...attributes.map(attribute => ` ${attribute}`), '>', content, '</GrButton>'].join('\n')
})
</script>
<template>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.15fr)_320px]">
<div class="grid gap-4">
<div
class="relative grid min-h-[280px] rounded-[24px] border border-dashed border-[var(--preview-brd)] bg-[image:var(--preview-surface)] p-6 pb-[72px]"
>
<div class="flex h-full flex-col items-center justify-center gap-4 text-center">
<div class="showcase-demo-caption text-xs">
Preview
</div>
<GrButton
:variant="variant"
:tone="tone"
:size="size"
:type="type"
:loading="loading"
:disabled="disabled"
:square="square"
:aria-label="effectiveAriaLabel"
>
<IconSparkles v-if="square && !loading" class="h-4 w-4" aria-hidden="true" />
<template v-else>
{{ buttonText }}
</template>
</GrButton>
<div
class="pointer-events-none absolute inset-x-6 bottom-6 flex justify-center border-t border-dashed border-[var(--preview-brd)] pt-2"
>
<div class="showcase-demo-text max-w-[40ch] text-center text-sm">
{{ previewSummary }}
</div>
</div>
</div>
</div>
<CodeBlock :code="previewCode" language="vue" expanded title="Rendered snippet" />
</div>
<div class="showcase-demo-panel grid gap-4 rounded-[28px] border p-4 lg:p-5">
<div class="showcase-demo-title text-sm font-semibold">
Properties
</div>
<div class="grid gap-4">
<GrFormField label="Variant">
<GrSelect v-model="variant" :options="variantOptions" aria-label="Variant" />
</GrFormField>
<GrFormField label="Tone">
<GrSelect v-model="tone" :options="toneOptions" aria-label="Tone" />
</GrFormField>
<GrFormField label="Size">
<GrRadioGroup v-model="size" :options="sizeOptions" variant="button" size="sm" />
</GrFormField>
<GrFormField label="Type">
<GrRadioGroup v-model="type" :options="typeOptions" variant="button" size="sm" />
</GrFormField>
<GrFormField label="Button label">
<GrInput
v-model="label"
:disabled="square"
placeholder="Create workspace"
aria-label="Button label"
/>
</GrFormField>
<GrFormField label="Accessibility label">
<GrInput
v-model="ariaLabel"
:placeholder="square ? 'Required for icon-only state' : 'Optional override for screen readers'"
aria-label="Accessibility label"
/>
</GrFormField>
</div>
<GrCard class="grid gap-3 p-4">
<GrSwitch v-model="loading" size="sm">
Loading
</GrSwitch>
<GrSwitch v-model="disabled" size="sm">
Disabled
</GrSwitch>
<GrSwitch v-model="square" size="sm">
Square / icon-only
</GrSwitch>
</GrCard>
</div>
</div>
</template>Tone × variant state matrix
tone: primary
Live + 4 states × 5 variants| state \ variant | primary | secondary | outline | ghost | ghost-border |
|---|---|---|---|---|---|
| Live | |||||
| Rest | |||||
| Hover | |||||
| Focus | |||||
| Active |
tone: neutral
Live + 4 states × 5 variants| state \ variant | primary | secondary | outline | ghost | ghost-border |
|---|---|---|---|---|---|
| Live | |||||
| Rest | |||||
| Hover | |||||
| Focus | |||||
| Active |
tone: success
Live + 4 states × 5 variants| state \ variant | primary | secondary | outline | ghost | ghost-border |
|---|---|---|---|---|---|
| Live | |||||
| Rest | |||||
| Hover | |||||
| Focus | |||||
| Active |
tone: warning
Live + 4 states × 5 variants| state \ variant | primary | secondary | outline | ghost | ghost-border |
|---|---|---|---|---|---|
| Live | |||||
| Rest | |||||
| Hover | |||||
| Focus | |||||
| Active |
tone: danger
Live + 4 states × 5 variants| state \ variant | primary | secondary | outline | ghost | ghost-border |
|---|---|---|---|---|---|
| Live | |||||
| Rest | |||||
| Hover | |||||
| Focus | |||||
| Active |
tone: info
Live + 4 states × 5 variants| state \ variant | primary | secondary | outline | ghost | ghost-border |
|---|---|---|---|---|---|
| Live | |||||
| Rest | |||||
| Hover | |||||
| Focus | |||||
| Active |
tone: slate
Live + 4 states × 5 variants| state \ variant | primary | secondary | outline | ghost | ghost-border |
|---|---|---|---|---|---|
| Live | |||||
| Rest | |||||
| Hover | |||||
| Focus | |||||
| Active |
tone: azure
Live + 4 states × 5 variants| state \ variant | primary | secondary | outline | ghost | ghost-border |
|---|---|---|---|---|---|
| Live | |||||
| Rest | |||||
| Hover | |||||
| Focus | |||||
| Active |