GrButton

  • ядро
  • Действия
  • API · 17 props

Берут, когда действие меняет состояние.

import { GrButton } from '@feugene/granularity/components/GrButton'

Обзор

Когда брать

  • действие меняет состояние — сохранить, удалить, отправить: адрес при этом не меняется;
  • действие идёт на сервер — loading блокирует повтор и объявляет ожидание;
  • важность действия видна — variant и tone разводят главное, второстепенное и опасное;
  • кнопка только из иконки — square вместе с ariaLabel: без имени такая кнопка безымянна;
  • нужна ссылка с видом кнопки — href/as подменяют тег, оставляя семантику ссылки.

Когда взять другое

НужноБерите
Переход меняет адресGrLink
Действий несколько в рядGrButtonGroup
Действий много, они прячутсяGrDropdownMenu
Переключается состояние «включено/выключено»GrSwitch
Переключается вид одного содержимогоGrSegmented

Варианты все тона и виды — настоящим компонентом

Варианты · GrButton
tone \ variantprimarysecondaryoutlineghostghost-border
primary
neutral
success
warning
danger
info
slate
azure

Размеры

  • xs
  • sm
  • md
  • lg

Состояния

  • обычная
  • disabled
  • loading

Матрица живая: наводите и нажимайте. Состояний наведения, фокуса и нажатия здесь нет — они за браузером, а не за пропом.

Playground контролы выводятся из API, а не пишутся руками

Сцена

Загружается…

0 изменено· состояние — в адресе

Примеры 4 у каждого — живое демо и его код

  • Панель с одним громким действием

    Второстепенные инструменты — variant="ghost-border": тихие в ряду, с рамкой под указателем. Слоты prefix и suffix держат иконку, число фильтров и сочетание клавиш, и подпись с ними не сталкивается. Квадратная (square) кнопка из одной иконки получает имя из aria-label; залита только та, что создаёт.

    Invoices
    Northwind TradersINV-2026-0418 · Overdue€5,166.50
    Contoso LtdINV-2026-0417 · Paid€820.00
    Tailspin ToysINV-2026-0416 · Sent€310.00
  • Сохранить и отменить в подвале формы

    type="submit" и type="reset" отдают работу форме: Enter сохраняет, а сброс перехватывается и возвращает сохранённое. Пока идёт запрос, loading оставляет кнопку в фокусе и не пускает второй клик, а loading-text говорит скринридеру, что сохраняется. Обе кнопки ждут изменений — пока сохранять и отменять нечего.

    Billing details
    Unsaved changes
  • Разрушительное действие — через осознанный шаг

    tone="danger" помечает то, что не отменить, а кнопка с контуром только открывает шаг. Последняя кнопка остаётся disabled, пока не набрано имя, занимает всю ширину через block и показывает loading, пока пространство удаляется. Отмена возвращает клавиатуру на кнопку-триггер через открытый метод focus().

    Danger zone
    Transfer ownershipHand the workspace to another admin.
    Delete workspaceInvoices, customers and 12 members lose access.

API порождается из пакета · разойтись не может

Props

PropTypeпо умолчаниюОписание
disabledboolean | undefinedfalseДействие недоступно: нативный `disabled` у `<button>`, у кнопки-ссылки — `aria-disabled` и снятый адрес. Цвета — из токенов, без прозрачности.
type"button" | "submit" | "reset" | undefined"button"Нативный `type` у `<button>`. По умолчанию `button`: кнопка в форме не отправляет её сама.
tone
"primary" | "neutral" | "success" | "warning" | "danger" | ""primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined
undefinedСмысл действия: цвет заливки, рамки и текста. `danger` — для необратимого, `neutral` — для второстепенного. По умолчанию `primary`; внутри `GrButtonGroup` — тон группы.
variantGrButtonVariant | undefinedundefinedВес кнопки: `primary` — заливка тона, `secondary` — мягкая подложка с рамкой, `outline` — только рамка, `ghost` — только текст, `ghost-border` — текст, рамка которого проявляется под указателем. По умолчанию `primary`; глобально — `componentDefaults`.
size"xs" | "sm" | "md" | "lg" | undefinedundefinedВысота и кегль по шкале контролов. По умолчанию `md`; берётся из группы и `GrConfigProvider`.
ariaLabelstring | undefinedundefinedДоступное имя кнопки, когда текста нет: `square` с одной иконкой без него безымянна.
loadingboolean | undefinedfalseДействие выполняется: спиннер на месте префикса, а без него — поверх подписи, ширина не меняется; `aria-busy` и перехват клика. Кнопка при этом остаётся в фокусе — нативный `disabled` не ставится.
loadingTextstring | undefinedundefinedi18n: что именно грузится. `aria-busy` сам по себе часть AT не объявляет.
squareboolean | undefinedundefinedРавные стороны — кнопка из одной иконки. Без текста ей нужен `ariaLabel`.
asstring | Component | undefinedundefinedПолиморфизм: кастомный корневой тег/компонент (например, RouterLink).
loadingIndicatorGrButtonLoadingIndicator | undefinedundefinedВид загрузки. `spinner` — спиннер; `shimmer` — свет проходит по подписи, а подпись, иконки и ширина остаются на месте. Без текстовой подписи (кнопка из иконки) мерцать нечему — там спиннер. `track` — отрезок бежит по нижней кромке, содержимое на месте. По умолчанию `spinner`; тема меняет его через `componentDefaults` («Глина» — `shimmer`, «Чертёж» — `track`).
prefixCellboolean | undefinedundefinedПрефикс в квадратной ячейке высотой с кнопку, за перегородкой. Только с подписью: у квадратной кнопки ячейка и есть кнопка. По умолчанию `false`; тема включает её через `componentDefaults` («Чертёж»).
blockboolean | undefinedfalseКнопка на всю ширину контейнера.
hrefstring | undefinedundefinedРендерит кнопку как `<a href>` (если не задан `as`).

Слоты

SlotTypeОписание
defaultanyСодержимое кнопки.
prefixanyАддон слева: иконка, счётчик. В состоянии загрузки уступает место спиннеру.
suffixanyАддон справа: шеврон, счётчик.

Методы

MethodsСигнатураОписание
focus() => voidПоставить фокус на кнопку — и когда корень тег, и когда компонент из `as`.
blur() => voidСнять фокус с кнопки.

Типы

Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.

Импорт
import type { GrButtonConfigurableProps, GrButtonVariant, GrButtonLoadingIndicator } from '@feugene/granularity/components/GrButton'

Файлdist/types/components/GrButton/config.d.ts

grButtonConfigconst
const grButtonConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrButton">;

Файлdist/types/components/GrButton/defaults.d.ts

GrButtonConfigurablePropsinterface

Пропы GrButton, которые можно задать глобально через <GrConfigProvider :component-defaults="{ GrButton: { … } }">. Набор закрытый и осознанный: только оформление. modelValue, обработчики и всё, что относится к конкретному экземпляру, через конфиг настраиваться не должно — иначе получим глобальное состояние, которое ищется часами.

interface GrButtonConfigurableProps {
    variant: GrButtonVariant;
    tone: GrButtonTone;
    size: GrButtonSize;
    square: boolean;
    loadingIndicator: GrButtonLoadingIndicator;
    prefixCell: boolean;
}

Файлdist/types/components/GrButton/grButtonStyles.d.ts

GrButtonVarianttype
type GrButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'ghost-border';
GrButtonLoadingIndicatortype

Вид загрузки: спиннер, свет по подписи или отрезок, бегущий по нижней кромке.

type GrButtonLoadingIndicator = 'spinner' | 'shimmer' | 'track';
GrButtonTonetype
type GrButtonTone = GrTone;
GrButtonSizetype
type GrButtonSize = GrComponentSize;

Файлdist/types/components/GrButton/safelist.d.ts

grButtonSafelistconst
const grButtonSafelist: string[];

Файлdist/types/components/GrButton/GrButton.vue.d.ts

GrButtonPropsinterface
interface GrButtonProps {
    /**
     * Вес кнопки: `primary` — заливка тона, `secondary` — мягкая подложка с
     * рамкой, `outline` — только рамка, `ghost` — только текст, `ghost-border` —
     * текст, рамка которого проявляется под указателем. По умолчанию `primary`;
     * глобально — `componentDefaults`.
     */
    variant?: GrButtonVariant;
    /**
     * Смысл действия: цвет заливки, рамки и текста. `danger` — для необратимого,
     * `neutral` — для второстепенного. По умолчанию `primary`; внутри
     * `GrButtonGroup` — тон группы.
     */
    tone?: GrButtonTone;
    /** Высота и кегль по шкале контролов. По умолчанию `md`; берётся из группы и `GrConfigProvider`. */
    size?: GrButtonSize;
    /**
     * Действие выполняется: спиннер на месте префикса, а без него — поверх
     * подписи, ширина не меняется; `aria-busy` и перехват клика. Кнопка при этом
     * остаётся в фокусе — нативный `disabled` не ставится.
     */
    loading?: boolean;
    /**
     * Вид загрузки. `spinner` — спиннер; `shimmer` — свет проходит по подписи, а
     * подпись, иконки и ширина остаются на месте. Без текстовой подписи (кнопка из
     * иконки) мерцать нечему — там спиннер. `track` — отрезок бежит по нижней
     * кромке, содержимое на месте. По умолчанию `spinner`; тема меняет его через
     * `componentDefaults` («Глина» — `shimmer`, «Чертёж» — `track`).
     */
    loadingIndicator?: GrButtonLoadingIndicator;
    /**
     * Префикс в квадратной ячейке высотой с кнопку, за перегородкой. Только с
     * подписью: у квадратной кнопки ячейка и есть кнопка. По умолчанию `false`;
     * тема включает её через `componentDefaults` («Чертёж»).
     */
    prefixCell?: boolean;
    /** i18n: что именно грузится. `aria-busy` сам по себе часть AT не объявляет. */
    loadingText?: string;
    /**
     * Действие недоступно: нативный `disabled` у `<button>`, у кнопки-ссылки —
     * `aria-disabled` и снятый адрес. Цвета — из токенов, без прозрачности.
     */
    disabled?: boolean;
    /** Равные стороны — кнопка из одной иконки. Без текста ей нужен `ariaLabel`. */
    square?: boolean;
    /** Кнопка на всю ширину контейнера. */
    block?: boolean;
    /** Нативный `type` у `<button>`. По умолчанию `button`: кнопка в форме не отправляет её сама. */
    type?: 'button' | 'submit' | 'reset';
    /** Доступное имя кнопки, когда текста нет: `square` с одной иконкой без него безымянна. */
    ariaLabel?: string;
    /** Полиморфизм: кастомный корневой тег/компонент (например, RouterLink). */
    as?: string | Component;
    /** Рендерит кнопку как `<a href>` (если не задан `as`). */
    href?: string;
    /** `target` кнопки-ссылки. С `_blank` сам добавляется `rel="noopener noreferrer"`. */
    target?: string;
    /** `rel` кнопки-ссылки. Без него при `target="_blank"` ставится `noopener noreferrer`. */
    rel?: string;
    /**
     * Открывать ссылку в новой вкладке: `target="_blank"` и `rel="noopener noreferrer"`.
     * Уход объявляется заранее — значком справа и скрытым текстом в имени.
     */
    external?: boolean;
}

Файлdist/types/components/GrButton/index.d.ts

GrButtonInstancetype
type GrButtonInstance = ComponentExposed<typeof GrButtonComponent>;

Использование из прозы библиотеки

disabled и loading — разные состояния

disabled ставит нативный атрибут (у <button>) и гасит кнопку токенами --gr-button-disabled-bg / -fg / -brd. Прозрачности здесь нет намеренно: opacity разбавляет выверенные на AA цвета, а кнопка чаще других компонентов стоит на цветной подложке, где разбавленный текст проваливается первым.

Сами эти токены — ссылки на общие роли --gr-disabled-bg/-fg/-brd, те же, что у вкладки, сегмента и бегунка: недоступное состояние во всём пакете выглядит одинаково и перекрашивается одним местом. Покомпонентная тройка остаётся точкой кастомизации на случай, когда кнопка обязана отличаться.

Приглушение работает и для кнопки-ссылки (href/as): нативный disabled ей не достаётся, поэтому цвета задаются классами, а не disabled:-вариантом.

Кнопку, которая должна остаться в таб-порядке, но объявлена недоступной, помечают атрибутом aria-disabled="true". Она получает тот же приглушённый вид, нативный disabled не ставится, а клик компонент не перехватывает — его гасит потребитель. Так GrTransfer держит перенос, которому некуда ехать: уронить фокус с нажатой кнопки в тело документа было бы хуже.

loading не ставит нативный disabled — элемент остался бы без фокуса, и скринридер потерял бы контекст. Вместо этого aria-busy, aria-disabled и перехват клика в capture-фазе. Выглядит кнопка при этом обычной: о состоянии говорит спиннер. Курсор — progress, а не not-allowed: кнопка занята, а не запрещена. Вдавливания на нажатие нет ни у ждущей, ни у отключённой — они нажатие не принимают и не изображают.

Загрузка не меняет ширину

С префиксом спиннер встаёт на его место. Без префикса ему некуда встать, и он ложится поверх содержимого, а содержимое гаснет: соседние кнопки в ряду не едут, как ехали бы от дописанного слева спиннера.

Гаснет оно прозрачностью, а не visibility — иначе подпись выпала бы из доступного имени. Голый текстовый узел селектором не достать, поэтому текст гасится -webkit-text-fill-color: color остаётся прежним, и спиннер рисуется цветом подписи.

vue
<GrButton :loading="saving" loading-text="Сохраняем отчёт">
Сохранить
</GrButton>

aria-busy сам по себе объявляет не всякая AT, поэтому во время загрузки к имени кнопки добавляется скрытый суффикс — loadingText или ключ gr.button.loading.

Свет по подписи вместо спиннера

loadingIndicator="shimmer" — другой сигнал той же загрузки: спиннера нет, свет проходит по глифам подписи, а подпись, иконки аддонов и ширина остаются на месте. aria-busy, скрытый суффикс и перехват клика — те же, что у спиннера.

vue
<GrButton tone="neutral" loading-indicator="shimmer" :loading="saving">
  Сохранить черновик
</GrButton>
  • Градиент — на подписи, а не на кнопке. background-clip: text на корне срезал бы его заливку, поэтому свет кладётся на обёртку текста; color не трогается, и иконки, нарисованные им, не мерцают.
  • Только у текстовой подписи. У кнопки из одной иконки мерцать нечему — там остаётся спиннер, иначе загрузку не было бы видно.
  • prefers-reduced-motion. Мерцания нет, подпись приглушена — другого сигнала, кроме неё, у этого вида загрузки нет.

Вид загрузки — оформление, поэтому его задаёт и тема (componentDefaults в theme.json: так он включён у «Глины»), и GrConfigProvider. Длительность прохода — токен темы --gr-button-shimmer-duration.

Отрезок по кромке вместо спиннера

loadingIndicator="track" — загрузка отрезком, бегущим по нижней кромке поверх рамки: подпись, иконки и ширина на месте, подпись чуть приглушена. Кромка есть у любой кнопки, поэтому квадратная тоже бежит отрезком, а не спиннером. aria-busy, скрытый суффикс и перехват клика — те же.

С prefers-reduced-motion отрезок не выключается, а замедляется: другого сигнала у загрузки нет. Период — токен темы --gr-button-track-duration; вид включает тема («Чертёж»).

Ячейка иконки

prefixCell ставит префикс в квадратную ячейку высотой с кнопку, за перегородкой в 24 % цвета подписи, и снимает боковой отступ с её стороны. Ячейка — только у кнопки с подписью: у квадратной кнопки ячейка и есть кнопка. Спиннер загрузки занимает ячейку, а не встаёт рядом. Включает её тема через componentDefaults («Чертёж»).

Слоты
vue
<GrButton>
  <template #prefix><GrIcon><IconPlus /></GrIcon></template>
  Добавить
  <template #suffix><GrKbd keys="mod+N" /></template>
</GrButton>

Во время loading спиннер занимает место префикса, а #prefix не рендерится: две иконки рядом читаются как ошибка вёрстки. У мерцания и отрезка префикс остаётся на месте — спиннера там нет.

Со стороны непустого аддона боковой отступ на 2px меньше — глиф стоит оптически по центру, а не прижатым к краю. Пустой слот (v-if внутри шаблона) отступ не трогает.

Иконка в слоте берёт ступень от кнопки: xs → 14px, sm и md → 16px, lg → 20px. Кнопка подсказывает её GrIcon контекстом; явный size у иконки сильнее подсказки, подсказка сильнее GrConfigProvider. Зазор между частями растёт так же: 4 / 6 / 8 / 8px.

Длинная подпись режется

Подпись из одного текста кнопка оборачивает и режет многоточием, а полный текст показывает нативной подсказкой — только когда строка правда обрезана. Доступное имя остаётся целым. Иконку или разметку в основном слоте кнопка не оборачивает: обёртка сдвинула бы их выравнивание, и квадратная кнопка перестала бы быть квадратом.

Фокус — контур с отступом

Фокус рисуется outline в 2px цветом --gr-ring, только на :focus-visible; отступ — токен темы --gr-button-focus-offset (у «Сланца» 2px). Контур может въезжать издалека: --gr-button-focus-offset-from — отступ в момент появления («Тактильная» — 6px). Кольцо вплотную к заливке того же тона читалось утолщением кнопки — индиго на индиго даёт 1.4:1, — а box-shadow в forced-colors выключается целиком. outline там остаётся и перекрашивается в системный цвет.

Веса не спорят с главным

У «Сланца» рамка secondary в покое — 38 % тона, outline — 70 %; полный тон рамка получает под указателем. Насыщенная рамка у второстепенного веса тянула взгляд мимо primary. Нейтральная secondary — «обычная» кнопка формы: поверхность карточки (--gr-button-neutral-soft-bg), рамка --gr-button-neutral-border и тень --gr-button-surface-shadow.

У solid-кнопки есть объём: тень темы (--gr-button-shadow), под ней — тень цвета тона (--gr-button-glow с цветом --gr-button-<тон>-glow) и слой поверх заливки (--gr-button-solid-image); в нажатии тень уходит внутрь (--gr-button-shadow-active). У «Сланца» это блик по верхней кромке и мелкая тень, тени тона и слоя нет. Отключённая кнопка объём теряет вместе с вариантом. Рамка в 1px есть у всех весов — у ghost прозрачная, — поэтому ghost в ряду той же ширины, что соседи.

Подпись — токен темы --gr-button-font-weight: у «Сланца» medium, у «Чертежа» 600.

Наведение и нажатие soft-заливки подмешивают тон в саму заливку, а не в фон. Поэтому они темнеют в светлом режиме и светлеют в тёмной — вслед за покоем.

Вид — у темы, механика — у кнопки

Форма, отклик и объём — токены вида theme: тема задаёт их значения, кнопка их только читает. Отсюда четыре разных кнопки на одном API — «Сланец», «Тактильная», «Чертёж» и «Глина»; разбор — ../design/GrButton.md.

  • Скругление — по ступени: --gr-button-radius-{xs,sm,md,lg}. Один радиус на все ступени — ручка приложения --gr-button-radius (999px — капсула); тема её не задаёт.
  • Отклик под указателем — слой ::before под содержимым: ::after занят зоной касания. Его положение, размер, цвет и рост задают --gr-button-response-*; цвет считается от currentColor, поэтому один токен годится всем тонам. У «Тактильной» это подложка прозрачных весов, у «Чертежа» — линия хода по нижней кромке, у «Сланца» слой прозрачен.
  • Подъём и темп — --gr-button-lift, --gr-button-motion-duration и -ease; с prefers-reduced-motion движения нет, остаётся смена цвета.
  • Недоступная кнопка берёт стиль рамки и слой поверх заливки у темы (--gr-button-disabled-border-style, -image): пунктир и штриховка читаются без цвета.
Нажатый переключатель держит вид нажатия

Кнопка с aria-pressed="true" — включённый переключатель — держит вид нажатого состояния своего веса (атрибут data-gr-pressed): иначе «включено» и «выключено» различались бы только для диктора. В лотке GrButtonGroup выбранное звено — лист поверх лотка, атрибута нет. aria-current вид не меняет: у ссылки это навигация, и её вид решает раскладка, а не кнопка.

Зона касания на тач-экране

На грубом указателе (pointer: coarse) кнопка ниже --gr-button-touch-size (44px) добирает высоту невидимым ::after. Вид и плотность ряда прежние: xs в строке таблицы остаётся 28px, а промахов пальцем нет.

Квадратная кнопка и block

square даёт кнопку с равными сторонами — размер приходит из --gr-button-square-size с дефолтом по размеру (1.75rem / 2rem / 2.5rem / 2.75rem). Переменную можно задать в своём CSS и подогнать кнопку под свою сетку:

css
.toolbar { --gr-button-square-size: 2.25rem; }

Переменная одна на все четыре ступени. Заданная выше по дереву — на :root, в теме — она схлопывает xs, sm, md и lg в одно число, и ступень перестаёт что-либо значить. Поэтому её место — узкий скоуп вроде примера выше, а не корень документа. Нужен размер по устройству ввода — задавайте его GrConfigProvider, а не переменной: приложение знает про свой указатель, пакет про него не знает.

block растягивает кнопку на ширину контейнера.

Полиморфный корень

as → <a href> → <button>. Ссылке автоматически достаётся rel="noopener noreferrer", если она открывается в новой вкладке (external или явный target="_blank"), а disabled убирает её из таб-порядка.

Уход в новую вкладку объявляется заранее, как у GrLink (WCAG 3.2.5): справа появляется значок, а к имени — скрытое «(откроется в новой вкладке)», ключ gr.button.opensInNewTab. При заданном ariaLabel подсказка дописывается в само имя, иначе aria-label перекрыл бы её. У квадратной кнопки значка нет — места под него нет, — но подсказка остаётся.