GrBadge

Package: @feugene/granularitycoreGroup: feedback

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

NeedTake
A counter on a button or an iconGrBadgeWrap
The label is dismissible or selectableGrChip; inside an input — GrInputTag
An action is neededGrButton
A number as a metricGrStatistic
A key or a shortcutGrKbd

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-brd pair, 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…

Code
<GrBadge />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
tone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefinedundefined
size"xs" | "sm" | "md" | "lg" | undefinedundefined
darkboolean | undefinedfalse
radiusGrBadgeRadius | undefinedundefined

Slots

SlotTypeDescription
defaultanyThe content of the label.
iconanyAn 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

Builderdepends on the showcase environment
<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

NeutralPrimaryInfoSuccessWarningDangerSlateAzure
NeutralPrimaryInfoSuccessWarningDangerSlateAzure

Variant Scale
<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

xs squarexs semixs roundsm squaresm semism roundmd squaremd semimd roundlg squarelg semilg round

Size Radius
<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

Status Icondepends on the showcase environment
<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

Active filter: all

Toolbar Filters
<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>

Component documentationAll components