GrStatistic

Package: @feugene/granularitycoreGroup: data

Highlights a key metric with a caption, formatting and a trend line.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • one metric is the main one — revenue, the number of orders, conversion: a large figure is read first;
  • a trend is needed beside ittrend with an arrow and the label “+12 % against last week”;
  • the number is long — digit grouping and precision by locale, with no manual formatting;
  • the value changes before your eyesanimate brings the figure to the new value rather than replacing it;
  • the tile is clickablehref/clickable lead into a section with the details.

When to take something else

NeedTake
There are several metrics and they are comparedGrChartBar
The course over time mattersGrChartLine / GrSparkline
A share of a whole is shownGrProgressCircle
The value is a status rather than a numberGrBadge
The quantity stands inside a line of text rather than as a tileGrDelta

The component is presentational: it does not load the data and does not compute the delta — it only formats and presents. The trend arrives ready, because “compared to what” is known by the application rather than by the tile.

Formatting by locale

<GrStatistic title="Revenue" :value="1234567.5" :precision="2" prefix="₽" />

The separators come from the locale through Intl.NumberFormat, and the locale from the i18n adapter of the package. Nothing has to be configured: ru will give 1 234 567,50, and de-DE 1.234.567,50.

The resolution order: groupSeparator/decimalSeparator → the locale prop → the locale of the adapter → the built-in default (a narrow space and a dot). An explicit separator replaces only its own part; the rest of the rules of the locale — grouping by three or by the Indian scheme, the minus sign — the component keeps.

A non-numeric value is printed as it is: “2 h 15 min” and ”—” need no workaround props. The formatting lives in a pure formatStatisticValue function of its own and is available from the package.

A mandatory value that did not arrive is drawn as a dash — the same sign of emptiness with which GrDelta shows a missing quantity — and is explained with a warning in dev mode. A tile with the word undefined looks like data rather than like an error, so that variant is excluded.

The trend

<GrStatistic :value="482" trend="up" trend-text="+12.5 % against last week" />

trend sets the colour and the icon of the line, and trendText the text itself. The direction icons are built in; the icon prop to the left of the block accepts a Vue component or an icon class from your build (see “Icons”). The direction is additionally announced with a hidden label (“Growth”, “Decline”, “No change”): the icon is decorative, and “+12.5 %” without context does not tell growth from decline — the colour is not visible to a screen reader either. The label stays with a #trend slot of your own as well.

The affixes are drawn by `GrValue`

<GrStatistic title="Average order" :value="14.99" :precision="2" prefix="$" suffix="per order" />

The tile does not draw the record of the quantity — the prefix, the number, the suffix — itself: that is done by the GrValue primitive, shared with GrDelta. The defaults come from there as well: on the left the affix is set like a number ($14.99), on the right it is dimmed and smaller (42 %).

The tile decides only the tone and the type size, and puts them on the container of the quantity — the affixes inherit both. That is why a negative sum turns red as a whole, together with the currency sign, rather than the number apart from it.

What an affix is — a currency, a unit of measurement or a note — is decided by nobody: the styling of both is configured with the --gr-value-* tokens. That is also how a currency on the right (1 284 500 ₽) is obtained, for which the default of the suffix does not fit — the recipe is on the page of the primitive.

The tone by sign and the trend line are different signals

<GrStatistic title="Margin" :value="-1240" prefix="₽" polarity="positive-good" />

polarity colours the value by the sign of the quantity itself: positive-good for revenue, negative-good for cost and churn, none when the sign says nothing about quality. Zero is neutral with any polarity, and a non-numeric value (“2 h 15 min”, ”—”) has no sign and gets no tone.

trend colours the line under the value and arrives ready. Neither requires the other: a metric may turn red without a label about the trend, and a label may stand under a neutral value.

An explicit tone is stronger than polarity: an inferred tone is a default rather than a dictate. The same choice of tone is available as a separate deltaTone function — see GrDelta, where it lives.

The counter animation

<GrStatistic title="Revenue" :value="revenue" animate :animate-duration="900" />

animate runs through the numbers when the tile appears (from zero) and on every change of the value — from the previous number rather than from zero: a run from zero on every update of a dashboard would read as a reset of the data.

The duration is set by animateDuration in milliseconds (600 by default). It is deliberately not set with a token: the --gr-duration-* scale ends at 300 ms and describes a change of state — a colour, a border, the appearance of a layer. A run through numbers is a different genre, and its number lives where the tween does — in JS.

Only numbers are run through: “2 h 15 min” and ”—” are set at once. The width of the line does not jerk — the value is set in tabular-nums.

The component reads prefers-reduced-motion itself. The global clamp in base.css holds CSS animations and transitions but not a JS tween — see ../motion.md. Under reduce the value is set instantly, with not a single intermediate frame.

A screen reader hears the final value. While the run is going on, the visible number is marked aria-hidden, and a visually hidden node with the result lives beside it: “1,284,500” on the screen and “743,210” in the ears is not noise but wrong data.

The step into the details

<GrStatistic title="Orders" :value="1284" href="/orders" />

<GrStatistic title="Orders" :value="1284" clickable @click="drill" />

<GrStatistic title="Orders" :value="1284" :as="RouterLink" :to="{ name: 'orders' }" />

The same device as in GrCard and GrListItem: href gives a link, clickable a button, as a tag or a router component of your own (stronger than href). An interactive tile gets a cursor, a highlight and a focus ring; it has no fill of its own — a metric usually lies in a card already, and a second surface would argue with it.

There is no separate ariaLabel: the accessible name is assembled from the content, and the label of the metric is its name.

Loading

loading replaces the value with a skeleton of the same height, so that the block does not jump; the area is marked role="status" and contains a hidden loading text.

Styling

PointWhat it sets
size (xslg)the ladders of the type sizes of the label, the value, the affixes and the trend; read from GrConfigProvider
tonethe colour of the value; the tones come from the -text tokens — a saturated tone as text does not pass by contrast
polarityinfers tone from the sign of the value itself; read from GrConfigProvider
--gr-statistic-value-colorthe colour of the value pointwise, stronger than tone
--gr-statistic-title-colorthe colour of the label

The #icon, #title, #prefix, #suffix and #trend slots and the default slot (instead of the formatted value) replace the corresponding parts with markup.

The markup: the label and the value are a pair

The label and the value are output as <dl><dt><dd>: that is a “term — value” pair rather than two neighbouring blocks a screen reader simply reads in a row. The <dl> appears only together with the label — a definition list without a <dt> would be the same incoherence, only with a claim to semantics.

The trend line stays outside the <dl>: only dt/dd groups are allowed inside a definition list. Its place in the DOM is unchanged, and the layout does not change. The margins of the <dl> and the <dd> are zeroed with a class — the preflight of the package resets them for body alone.

All of the data attributes (data-gr-statistic-title, data-gr-statistic-value, …) are kept: the consumer’s styles written against them keep working.

Playground 15

Loading…

Code
<GrStatistic />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
tone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefinedundefinedThe tone of the value; overridden pointwise by `--gr-statistic-value-color`.
titlestring | undefinedundefinedThe label above the value.
iconstring | Component | undefinedundefinedAn icon to the left of the block: a Vue component or the class of an icon from your UnoCSS build (`'i-lucide-users'` — then your `presetIcons` is needed, see `docs/installation.md`).
size"xs" | "sm" | "md" | "lg" | undefinedundefined
loadingboolean | undefinedfalseThe loading state: a placeholder instead of the value.
animateboolean | undefinedfalseRun through the numbers on appearing and on a change of the value. A non-numeric value is put in at once, and under `prefers-reduced-motion: reduce` so is any other.
asstring | Component | undefinedundefinedA root tag of your own (`RouterLink`, Inertia’s `Link`). Stronger than `href`.
hrefstring | undefinedundefinedA metric as a link: a move to the details.
clickableboolean | undefinedfalseA metric as a button: the whole tile is interactive.
localestring | undefinedundefinedThe BCP-47 locale of the formatting. Unset — it is taken from the i18n adapter; with no adapter either, the manual separators work.
precisionnumber | undefinedundefinedThe number of digits after the decimal point.
prefixstring | undefinedundefinedAn addition before the value (a currency, a sign).
suffixstring | undefinedundefinedAn addition after the value (a unit of measurement, `%`).
polarityGrDeltaPolarity | undefinedundefinedWhat counts as good: the tone is derived from the sign of the value itself. For revenue growth is a success, for the cost price and churn it is the other way round. An explicit `tone` is stronger: the derived tone is a default rather than a dictate.
decimalSeparatorstring | undefinedundefinedThe decimal separator. Stronger than the locale; without it and without a locale — a full stop.
groupSeparatorstring | undefinedundefinedThe digit-group separator. Stronger than the locale; without it and without a locale — a narrow space.
trendGrStatisticTrend | undefinedundefinedThe direction of the dynamics — it sets the colour and the icon of the line under the value.
trendTextstring | undefinedundefinedThe text of the dynamics (`+12.5% against last week`, for instance).
animateDurationnumber | undefined600The duration of the run-through in milliseconds. Not a token: the `--gr-duration-*` scale ends at 300 ms and describes a change of state, whereas running through numbers is a different genre, and its number lives where the tween lives.
valuerequiredstring | numberThe value of the metric. A non-numeric string is printed as it is.

Slots

SlotTypeDescription
defaultanyThe value instead of the `value` prop.
iconanyAn icon before the label.
titleanyThe label of the metric instead of the `title` prop.
prefixanyAn addition before the value: a currency sign, an arrow.
suffixanyAn addition after the value: a unit of measurement, a per cent.
trendanyThe dynamics under the value instead of the built-in `GrDelta`.

Events

EventTypeDescription
click[event: MouseEvent]

Examples 5

KPI row

Revenue
$1,284,500
Active users
18,342
Conversion
4.8%

Basic
<script setup lang="ts">
import { GrCard, GrStatistic } from '@feugene/granularity'
</script>

<template>
  <div class="grid gap-4 sm:grid-cols-3">
    <GrCard class="p-4">
      <GrStatistic
        title="Revenue"
        :value="1284500"
        :precision="0"
        prefix="$"
        icon="i-lucide-wallet"
      />
    </GrCard>

    <GrCard class="p-4">
      <GrStatistic
        title="Active users"
        :value="18342"
        icon="i-lucide-users"
      />
    </GrCard>

    <GrCard class="p-4">
      <GrStatistic
        title="Conversion"
        :value="4.8"
        :precision="1"
        suffix="%"
        icon="i-lucide-target"
      />
    </GrCard>
  </div>
</template>

Counting tiles that lead somewhere

Conversion
4.8%
Revenue is a link, active users is a button (opened 0 times), conversion counts for 900 ms. Turn on "reduce motion" in the OS and the numbers stop counting.

Dashboard
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrCard, GrStatistic } from '@feugene/granularity'

const revenue = ref(1284500)
const users = ref(18342)
const conversion = ref(4.8)
const opened = ref(0)

/** Обновление дашборда: перебор идёт от прежнего числа, а не от нуля. */
function refresh() {
  revenue.value = Math.round(900000 + Math.random() * 700000)
  users.value = Math.round(12000 + Math.random() * 12000)
  conversion.value = Number((3 + Math.random() * 4).toFixed(1))
}
</script>

<template>
  <div class="grid gap-4">
    <div class="grid gap-4 sm:grid-cols-3">
      <GrCard class="p-4">
        <GrStatistic
          title="Revenue"
          :value="revenue"
          :precision="0"
          prefix="$"
          icon="i-lucide-wallet"
          animate
          href="#gr-statistic"
          trend="up"
          trend-text="+12.5% vs last week"
        />
      </GrCard>

      <GrCard class="p-4">
        <GrStatistic
          title="Active users"
          :value="users"
          icon="i-lucide-users"
          animate
          clickable
          @click="opened++"
        />
      </GrCard>

      <GrCard class="p-4">
        <GrStatistic
          title="Conversion"
          :value="conversion"
          :precision="1"
          suffix="%"
          icon="i-lucide-target"
          animate
          :animate-duration="900"
        />
      </GrCard>
    </div>

    <div class="flex flex-wrap items-center gap-3">
      <GrButton size="sm" variant="secondary" @click="refresh">
        Refresh data
      </GrButton>

      <span class="text-sm text-[var(--gr-muted-fg)]">
        Revenue is a link, active users is a button (opened {{ opened }} times), conversion counts for 900 ms.
        Turn on "reduce motion" in the OS and the numbers stop counting.
      </span>
    </div>
  </div>
</template>

Trend and loading

Orders
2,148
Increase+12.5% week over week
Refunds
97
Decrease-3.1% week over week
Average check
5,980.40
No changeNo change

Trend
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrCard, GrStatistic } from '@feugene/granularity'

const loading = ref(false)

function refresh(): void {
  loading.value = true
  setTimeout(() => {
    loading.value = false
  }, 1200)
}
</script>

<template>
  <div class="grid gap-4">
    <div class="grid gap-4 sm:grid-cols-3">
      <GrCard class="p-4">
        <GrStatistic
          title="Orders"
          :value="2148"
          tone="success"
          trend="up"
          trend-text="+12.5% week over week"
          :loading="loading"
        />
      </GrCard>

      <GrCard class="p-4">
        <GrStatistic
          title="Refunds"
          :value="97"
          tone="danger"
          trend="down"
          trend-text="-3.1% week over week"
          :loading="loading"
        />
      </GrCard>

      <GrCard class="p-4">
        <GrStatistic
          title="Average check"
          :value="5980.4"
          :precision="2"
          suffix=""
          trend="flat"
          trend-text="No change"
          :loading="loading"
        />
      </GrCard>
    </div>

    <div>
      <GrButton size="sm" @click="refresh">
        Refresh data
      </GrButton>
    </div>
  </div>
</template>

Tone from the sign of the value

Margin
-1,240
Cost of goods
-1,240
Balance
-1,240

Polarity
<script setup lang="ts">
import { ref } from 'vue'

import { GrCard, GrSegmented, GrStatistic } from '@feugene/granularity'

const margin = ref(-1240)

const presets = [
  { value: 4820, label: 'Profit' },
  { value: 0, label: 'Break even' },
  { value: -1240, label: 'Loss' },
]
</script>

<template>
  <div class="grid gap-4">
    <GrSegmented
      v-model="margin"
      :options="presets"
      size="sm"
    />

    <div class="grid gap-4 sm:grid-cols-3">
      <GrCard class="p-4">
        <!--
          Тон выводится из знака самой величины: полярность говорит, что здесь
          считать хорошим, а не какой краской красить.
        -->
        <GrStatistic
          title="Margin"
          :value="margin"
          prefix=""
          polarity="positive-good"
        />
      </GrCard>

      <GrCard class="p-4">
        <!-- У себестоимости рост — проблема, и тон обязан быть зеркальным. -->
        <GrStatistic
          title="Cost of goods"
          :value="margin"
          prefix=""
          polarity="negative-good"
        />
      </GrCard>

      <GrCard class="p-4">
        <!-- Явный `tone` сильнее: выведенный тон — умолчание, а не диктат. -->
        <GrStatistic
          title="Balance"
          :value="margin"
          prefix=""
          polarity="positive-good"
          tone="neutral"
        />
      </GrCard>
    </div>
  </div>
</template>

Slots and non-numeric values

Uptime
99.982%
SLA met
Time to first response
2 h 15 min
Target — under 4 hours

Slots
<script setup lang="ts">
import { GrBadge, GrCard, GrStatistic } from '@feugene/granularity'

const uptime = '99.982'
</script>

<template>
  <div class="grid gap-4 sm:grid-cols-2">
    <GrCard class="p-4">
      <GrStatistic title="Uptime" :value="uptime" :precision="3" suffix="%" size="lg" tone="success">
        <template #trend>
          <GrBadge tone="success" size="xs">
            SLA met
          </GrBadge>
        </template>
      </GrStatistic>
    </GrCard>

    <GrCard class="p-4">
      <GrStatistic title="Time to first response" value="2 h 15 min" size="sm">
        <template #icon>
          <span class="i-lucide-clock block h-4 w-4" aria-hidden="true" />
        </template>
        <template #trend>
          <span>Target — under 4 hours</span>
        </template>
      </GrStatistic>
    </GrCard>
  </div>
</template>

Component documentationAll components