GrBadge
A short label for a status, category or counter.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- the status needs a word — “Paid”, “Draft”, “Overdue”: the tone carries meaning, not only colour;
- the label sits in the flow — in a table row, in a card, next to a heading;
- there are many categories — the whole tone scale of the package without colours of your own;
- the label does nothing — the component is presentational: it is not clickable and leads nowhere.
When to take something else
| Need | Take |
|---|---|
| A counter on a button or an icon | GrBadgeWrap |
| The label is dismissible or selectable | GrChip; inside an input — GrInputTag |
| An action is needed | GrButton |
| A number as a metric | GrStatistic |
| A key or a shortcut | GrKbd |
Two weights: tinted and filled
dark switches the soft tinted background for a solid fill.
The fill goes through the per-component layer --gr-badge-{tone}-bg/-fg rather
than straight from the tone role, because the weight the themes need is
different. In the light theme --gr-{tone} is a bright fill under dark text,
and a filled badge read as a heavy, almost black plate; there the layer moves it
to -solid/-solid-fg — a button-weight fill under light text, the same one
solid buttons use. In the dark theme a pastel fill with dark text is the regular
convention of the package, and the layer leaves the roles as they are.
The values of the layer live in the themes of the component
(GrBadge/themes/{light,dark}.css), so a badge can be recoloured without
touching the tone roles as a whole.
Why text on a tint is always `-text`
A saturated tone as the text colour on its own light background gives 2.24:1;
the -text role gives 6.78:1. That is why the soft weight colours text with
-text roles only.
Both guarantees are held by the grBadgeContrast.test.ts gate, and neither is
formal:
- text contrast — the AA threshold of 4.5:1. There is no large-text concession here: a badge is set at 11–14px;
- the visibility of the plate itself is measured as the perceptual distance
to the page background (ΔE, noticeability threshold 2.3). A contrast rule alone
is not enough for this: the 3:1 of WCAG 1.4.11 is not met even by the regular
--gr-muted/--gr-brdpair, and a badge could end up indistinguishable from the page while formally breaking nothing.
On tone roles and their suffixes — ../theming.md.
Radius
radius is round (the default), semi or square. semi has its own ladder
across the size steps — 3/3/5/7 px through --gr-badge-semi-radius-*: the badge
rounds more slowly than it grows, otherwise at lg it would turn into a pill
sooner than the layout wants it to.
<GrBadge radius="semi" size="lg">
SKU-14920
</GrBadge>The text is centred optically
The height of the badge is held by an inner wrapper (min-height: 1lh — the
height of the line box at leading-none), while the text itself is trimmed to
cap height (text-box-trim) and centred inside that height. The label then sits
level regardless of whether it has descenders: “Paid” and “Awaiting payment” sit
the same.
The trimming lives on a nested node rather than on the text itself, and that
is not a matter of style: text-box-trim shrinks the line box, and hung directly
on the text it would drag the height of the whole badge down with it.
An icon before the label — the `icon` slot
A status often consists of more than a word: “parsing” without a spinner reads as a final state rather than as work in progress.
<GrBadge tone="warning">
<template #icon>
<IconLoader class="h-full w-full animate-spin" />
</template>
Recognising
</GrBadge>
The size is held by the slot wrapper, and the content stretches to it
(h-full w-full) — the same device as in GrChip, and for the
same reason: the icon scale has to follow the size of the badge rather than the
markup at the call site. It grows more slowly than the type: at the lower steps
an icon matched one-to-one with the text would blow out a narrow plate.
The icon is a sibling of the trimmed text, not its content: text-box-trim
lives on the nested node of the label and does not reach the icon, so the icon
does not stretch the height of the plate.
Do not put GrProgressCircle here: a circular indicator is on the widget
scale, its lowest step is 2rem, and it does not fit into the line of a badge —
the arc runs outside the background and pushes the text out. A spinner here is an
ordinary icon with animate-spin.
The slot does not make the badge interactive: it is still a label. If you need a
dismissible or selectable tag, that is GrChip, with its own role
and its own keyboard.
Size
size takes its type size from the control scale --gr-control-text-*
rather than the content one: a badge more often stands in a row with buttons and
fields than inside a paragraph. The scales and the difference between them —
../sizes.md.
tone, size and radius are read from GrConfigProvider, so the look of
badges is set once for the whole application.
Playground 3
Loading…
<GrBadge />Install
npm i @feugene/granularityImport
import { GrBadge } from '@feugene/granularity/components/GrBadge'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
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 | Description |
|---|---|---|
default | any | The content of the label. |
icon | any | An icon before the label: a status with a spinner, a flag, a type sign. The size is set by the wrapper from the `size` step, so the content of the slot stretches to it — `class="h-full w-full"`, as in `GrChip`. The rotation of a spinner is `animate-spin` on the icon itself. `GrProgressCircle` is not put here: a circular indicator is on the widget scale, its lowest step is `2rem`, and it does not fit into the line of a badge. |
Examples 5
Interactive badge constructor
<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>Light and dark semantic tones
<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 and radius combinations
<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>Status badge with a leading icon
<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>Badges inside action toolbars
<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>