GrBadge
Берут, когда статус нужен словом.
Когда брать
- статус нужен словом — «Оплачено», «Черновик», «Просрочен»: тон несёт смысл, а не только цвет;
- метка стоит в потоке — в строке таблицы, в карточке, рядом с заголовком;
- категорий много — вся шкала тонов пакета без своих цветов;
- метка ничего не делает — компонент презентационный: не кликается и никуда не ведёт.
Когда взять другое
| Нужно | Берите |
|---|---|
| Счётчик поверх кнопки или иконки | GrBadgeWrap |
| Метка снимается или выбирается | GrChip; в поле ввода — GrInputTag |
| Нужно действие | GrButton |
| Число как показатель | GrStatistic |
| Клавиша или сочетание | GrKbd |
Два веса: подложка и заливка
dark переключает мягкую тонированную подложку на плотную заливку.
Заливка идёт через покомпонентный слой --gr-badge-{tone}-bg/-fg, а не прямо
из роли тона, потому что нужный вес у тем разный. В светлой теме
--gr-{tone} — это яркая заливка под тёмный текст, и filled-бейдж читался
тяжёлой почти чёрной плашкой; там слой уводит его на -solid/-solid-fg —
заливку кнопочного веса под светлый текст, ту же, что у solid-кнопок. В тёмной
теме пастельная заливка с тёмным текстом — штатная конвенция пакета, и слой
оставляет прежние роли.
Значения слоя лежат в темах компонента (GrBadge/themes/{light,dark}.css),
поэтому перекрасить бейдж можно, не трогая роли тонов целиком.
Почему текст на подложке — всегда `-text`
Насыщенный тон как цвет текста на своей же светлой подложке даёт 2.24:1, роль
-text — 6.78:1. Поэтому мягкий вес красит текст только -text-ролями.
Обе гарантии закреплены гейтом grBadgeContrast.test.ts, и обе не формальные:
- контраст текста — порог AA 4.5:1. Послаблений для крупного текста здесь нет: кегль бейджа 11–14px;
- видимость самой плашки меряется перцептивным расстоянием до фона страницы
(ΔE, порог заметности 2.3). Контрастного правила для этого мало: 3:1 из WCAG
1.4.11 не проходит даже штатная пара
--gr-muted/--gr-brd, и бейдж мог оказаться неотличимым от страницы, формально не нарушив ничего.
Про роли тонов и суффиксы — ../theming.md.
Радиус
radius — round (по умолчанию), semi или square. У semi своя лестница
по ступеням размера — 3/3/5/7 px через --gr-badge-semi-radius-*: бейдж
скругляется медленнее, чем растёт, иначе на lg он превращается в пилюлю
раньше, чем этого хочет макет.
<GrBadge radius="semi" size="lg">
SKU-14920
</GrBadge>Текст центрируется оптически
Высоту бейджа держит внутренняя обёртка (min-height: 1lh — высота line-box
при leading-none), а сам текст обрезается до cap-height (text-box-trim) и
центрируется внутри этой высоты. Так подпись стоит ровно независимо от того,
есть ли в ней выносные элементы: «Оплачено» и «Ждём оплаты» садятся одинаково.
Обрезка живёт на вложенном узле, а не на самом тексте, и это не стилистика:
text-box-trim уменьшает line-box, и повешенный прямо на текст он просадил бы
вместе с ним высоту всего бейджа.
Иконка перед подписью — слот `icon`
Статус часто состоит не только из слова: «идёт разбор» без спиннера читается как окончательное состояние, а не как работа.
<GrBadge tone="warning">
<template #icon>
<IconLoader class="h-full w-full animate-spin" />
</template>
На распознании
</GrBadge>
Размер держит обёртка слота, а содержимое тянется до неё (h-full w-full) —
тот же приём, что у GrChip, и по той же причине: шкала иконки
обязана следовать за size бейджа, а не за разметкой места вызова. Растёт она
медленнее кегля: на нижних ступенях иконка «один в один с текстом» разогнала бы
узкую плашку.
Иконка — сосед обрезанного текста, а не его содержимое: text-box-trim
живёт на вложенном узле подписи и до иконки не дотягивается, поэтому высоту
плашки она не тянет.
Не кладите сюда GrProgressCircle: у кругового индикатора шкала виджетная,
нижняя ступень — 2rem, и в строку бейджа он не помещается — дуга уезжает за
подложку, вынося текст наружу. Спиннер здесь — обычная иконка с animate-spin.
Слот не делает бейдж интерактивным: он по-прежнему метка. Нужен снимаемый или
выбираемый тег — это GrChip, у него своя роль и своя
клавиатура.
Размер
size берёт кегль из контрольной шкалы --gr-control-text-*, а не из
контентной: бейдж чаще стоит в одном ряду с кнопками и полями, чем внутри
абзаца. Шкалы и их разница — ../sizes.md.
tone, size и radius читаются из GrConfigProvider, поэтому вид бейджей
задаётся один раз на приложение.
Playground 3
Загружается…
<GrBadge />Установка
npm i @feugene/granularityИмпорт
import { GrBadge } from '@feugene/granularity/components/GrBadge'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
tone | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined | undefined | — |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
dark | boolean | undefined | false | — |
radius | GrBadgeRadius | undefined | undefined | — |
Slots
| Slot | Type | Описание |
|---|---|---|
default | any | Содержимое метки. |
icon | any | Иконка перед подписью: статус со спиннером, флаг, значок типа. Размер задаёт обёртка по ступени `size`, поэтому содержимое слота тянется до неё — `class="h-full w-full"`, как у `GrChip`. Вращение спиннера — `animate-spin` на самой иконке. `GrProgressCircle` сюда не кладут: у кругового индикатора шкала виджетная, нижняя ступень — `2rem`, и в строку бейджа он не помещается. |
Примеры 5
Интерактивный конструктор бейджа
Соберите GrBadge под ваш сценарий: переключайте tone, size, radius, filled-mode и текст лейбла, сразу видя итоговый snippet.
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
GrBadge,
GrFormField,
GrInput,
GrRadioGroup,
GrSelect,
GrSwitch,
type GrBadgeRadius,
type GrBadgeSize,
type GrBadgeTone,
} from '@feugene/granularity'
import CodeBlock from '../../../components/doc/CodeBlock.vue'
const tone = ref<GrBadgeTone>('primary')
const size = ref<GrBadgeSize>('sm')
const radius = ref<GrBadgeRadius>('round')
const label = ref('Beta')
const dark = ref(false)
const uppercase = ref(false)
const toneOptions = [
{ value: 'neutral', label: 'Neutral' },
{ value: 'primary', label: 'Primary' },
{ value: 'info', label: 'Info' },
{ value: 'success', label: 'Success' },
{ value: 'warning', label: 'Warning' },
{ value: 'danger', label: 'Danger' },
{ value: 'slate', label: 'Slate' },
{ value: 'azure', label: 'Azure' },
] satisfies Array<{ value: GrBadgeTone, label: string }>
const sizeOptions = [
{ value: 'xs', label: 'XS' },
{ value: 'sm', label: 'SM' },
{ value: 'md', label: 'MD' },
{ value: 'lg', label: 'LG' },
] satisfies Array<{ value: GrBadgeSize, label: string }>
const radiusOptions = [
{ value: 'square', label: 'Square' },
{ value: 'semi', label: 'Semi' },
{ value: 'round', label: 'Round' },
] satisfies Array<{ value: GrBadgeRadius, label: string }>
const badgeText = computed(() => {
const value = label.value.trim()
return value || 'Beta'
})
const previewSummary = computed(() => {
if (dark.value)
return 'A filled (`dark`) badge works better as a bright status indicator inside tables, toolbar counters and alert summaries.'
if (radius.value === 'square')
return 'A square badge gives the densest silhouette and suits compact row-level labels and counters.'
if (radius.value === 'semi')
return 'The semi radius is visually closer to a filter chip and is handy where you need a slightly stricter outline without a full pill effect.'
if (tone.value === 'neutral')
return 'A neutral light badge is a safe default for metadata, secondary statuses and supporting captions.'
return 'Assemble the right combination of `tone`, `size`, `radius` and `dark` to quickly check the badge before using it in the interface.'
})
const previewLabelClass = computed(() => {
return uppercase.value ? 'uppercase tracking-[0.08em]' : ''
})
const previewCode = computed(() => {
const attributes = [
`tone="${tone.value}"`,
`size="${size.value}"`,
`radius="${radius.value}"`,
]
if (dark.value)
attributes.push('dark')
const content = uppercase.value ? badgeText.value.toUpperCase() : badgeText.value
return ['<GrBadge', ...attributes.map(attribute => ` ${attribute}`), '>', ` ${content}`, '</GrBadge>'].join('\n')
})
</script>
<template>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.05fr)_320px]">
<div class="grid gap-4">
<div
class="relative grid min-h-[240px] 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-5 text-center">
<div class="showcase-demo-caption text-xs">
Preview
</div>
<div class="flex flex-wrap items-center justify-center gap-3">
<GrBadge :tone="tone" :size="size" :radius="radius" :dark="dark">
<span :class="previewLabelClass">{{ uppercase ? badgeText.toUpperCase() : badgeText }}</span>
</GrBadge>
<span class="showcase-demo-text text-sm text-[var(--gr-muted-fg)]">
SLA status
</span>
</div>
<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-[44ch] 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">
Badge properties
</div>
<div class="grid gap-4">
<GrFormField label="Tone">
<GrSelect v-model="tone" :options="toneOptions" aria-label="Badge tone" />
</GrFormField>
<GrFormField label="Size">
<GrRadioGroup v-model="size" :options="sizeOptions" variant="button" size="sm" />
</GrFormField>
<GrFormField label="Radius">
<GrRadioGroup v-model="radius" :options="radiusOptions" variant="button" size="sm" />
</GrFormField>
<GrFormField label="Label">
<GrInput v-model="label" placeholder="Beta" aria-label="Badge label" />
</GrFormField>
</div>
<div class="grid gap-3 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
<GrSwitch v-model="dark" size="sm">
Filled / dark mode
</GrSwitch>
<GrSwitch v-model="uppercase" size="sm">
Uppercase label
</GrSwitch>
</div>
</div>
</div>
</template>Светлые и плотные семантические тона
Сценарий работает как справочник по semantic palette: light и filled (dark) режимы удобно сравнить бок о бок, включая slate и azure.
<script setup lang="ts">
import { GrBadge } from '@feugene/granularity'
</script>
<template>
<div class="grid gap-4">
<div class="flex flex-wrap items-center gap-2">
<GrBadge>Neutral</GrBadge>
<GrBadge tone="primary">Primary</GrBadge>
<GrBadge tone="info">Info</GrBadge>
<GrBadge tone="success">Success</GrBadge>
<GrBadge tone="warning">Warning</GrBadge>
<GrBadge tone="danger">Danger</GrBadge>
<GrBadge tone="slate">Slate</GrBadge>
<GrBadge tone="azure">Azure</GrBadge>
</div>
<div class="flex flex-wrap items-center gap-2">
<GrBadge dark>Neutral</GrBadge>
<GrBadge tone="primary" dark>Primary</GrBadge>
<GrBadge tone="info" dark>Info</GrBadge>
<GrBadge tone="success" dark>Success</GrBadge>
<GrBadge tone="warning" dark>Warning</GrBadge>
<GrBadge tone="danger" dark>Danger</GrBadge>
<GrBadge tone="slate" dark>Slate</GrBadge>
<GrBadge tone="azure" dark>Azure</GrBadge>
</div>
</div>
</template>Сочетания размера и радиуса
Отдельно выделяем size и radius, чтобы quickly show pill/semi/square badges для table cells, filters и inline labels.
<script setup lang="ts">
import { GrBadge } from '@feugene/granularity'
</script>
<template>
<div class="flex flex-wrap items-center gap-2">
<GrBadge size="xs" tone="success" radius="square">xs square</GrBadge>
<GrBadge size="xs" tone="success" radius="semi">xs semi</GrBadge>
<GrBadge size="xs" tone="success" radius="round">xs round</GrBadge>
<GrBadge size="sm" tone="success" radius="square">sm square</GrBadge>
<GrBadge size="sm" tone="success" radius="semi">sm semi</GrBadge>
<GrBadge size="sm" tone="success" radius="round">sm round</GrBadge>
<GrBadge size="md" tone="warning" radius="square">md square</GrBadge>
<GrBadge size="md" tone="warning" radius="semi">md semi</GrBadge>
<GrBadge size="md" tone="warning" radius="round">md round</GrBadge>
<GrBadge size="lg" tone="info" radius="square">lg square</GrBadge>
<GrBadge size="lg" tone="info" radius="semi">lg semi</GrBadge>
<GrBadge size="lg" tone="info" radius="round">lg round</GrBadge>
</div>
</template>Бейдж статуса с иконкой перед подписью
Слот #icon ставит значок перед подписью: спиннер у идущей работы, галочку у результата, крестик у ошибки. Размер иконки идёт за size бейджа, поэтому ряд статусов не разъезжается.
<script setup lang="ts">
import IconCheck from '~icons/lucide/check'
import IconLoader from '~icons/lucide/loader-circle'
import IconX from '~icons/lucide/x'
import { GrBadge } from '@feugene/granularity'
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-wrap items-center gap-2">
<GrBadge tone="warning">
<template #icon>
<IconLoader class="h-full w-full animate-spin" />
</template>
На распознании
</GrBadge>
<GrBadge tone="success">
<template #icon>
<IconCheck class="h-full w-full" />
</template>
Распознано
</GrBadge>
<GrBadge tone="danger">
<template #icon>
<IconX class="h-full w-full" />
</template>
Ошибка распознавания
</GrBadge>
<GrBadge tone="neutral">
Не распознан
</GrBadge>
</div>
<div class="flex flex-wrap items-center gap-2">
<GrBadge v-for="size in (['xs', 'sm', 'md', 'lg'] as const)" :key="size" :size tone="warning">
<template #icon>
<IconLoader class="h-full w-full animate-spin" />
</template>
{{ size }} — на распознании
</GrBadge>
</div>
</div>
</template>Спиннер — обычная иконка с animate-spin. GrProgressCircle в бейдж не кладут: его нижняя ступень равна 2rem и разносит плашку.
Бейджи внутри панелей действий
Компонент часто используется не сам по себе, а как secondary marker внутри toolbar/filter buttons. Этот сценарий показывает composition-паттерн.
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrButton, GrButtonGroup, GrCard } from '@feugene/granularity'
const activeFilter = ref<'all' | 'failed' | 'review'>('all')
</script>
<template>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<GrButtonGroup aria-label="Filter pipelines">
<GrButton :variant="activeFilter === 'all' ? 'primary' : 'outline'" @click="activeFilter = 'all'">
All
</GrButton>
<GrButton :variant="activeFilter === 'failed' ? 'primary' : 'outline'" @click="activeFilter = 'failed'">
Failed
<GrBadge class="ml-2" size="sm" radius="semi" dark tone="azure">3</GrBadge>
</GrButton>
<GrButton :variant="activeFilter === 'review' ? 'primary' : 'outline'" @click="activeFilter = 'review'">
Needs review
<GrBadge class="ml-2" size="sm" tone="warning" radius="semi">7</GrBadge>
</GrButton>
</GrButtonGroup>
<GrCard class="p-4 text-sm text-[var(--gr-muted-fg)]">
Active filter: <span class="font-semibold text-[var(--gr-fg)]">{{ activeFilter }}</span>
</GrCard>
</div>
</template>