GrStatistic
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 it —
trendwith an arrow and the label “+12 % against last week”; - the number is long — digit grouping and
precisionby locale, with no manual formatting; - the value changes before your eyes —
animatebrings the figure to the new value rather than replacing it; - the tile is clickable —
href/clickablelead into a section with the details.
When to take something else
| Need | Take |
|---|---|
| There are several metrics and they are compared | GrChartBar |
| The course over time matters | GrChartLine / GrSparkline |
| A share of a whole is shown | GrProgressCircle |
| The value is a status rather than a number | GrBadge |
| The quantity stands inside a line of text rather than as a tile | GrDelta |
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
| Point | What it sets |
|---|---|
size (xs…lg) | the ladders of the type sizes of the label, the value, the affixes and the trend; read from GrConfigProvider |
tone | the colour of the value; the tones come from the -text tokens — a saturated tone as text does not pass by contrast |
polarity | infers tone from the sign of the value itself; read from GrConfigProvider |
--gr-statistic-value-color | the colour of the value pointwise, stronger than tone |
--gr-statistic-title-color | the 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…
<GrStatistic />Install
npm i @feugene/granularityImport
import { GrStatistic } from '@feugene/granularity/components/GrStatistic'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
tone | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined | undefined | The tone of the value; overridden pointwise by `--gr-statistic-value-color`. |
title | string | undefined | undefined | The label above the value. |
icon | string | Component | undefined | undefined | An 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" | undefined | undefined | — |
loading | boolean | undefined | false | The loading state: a placeholder instead of the value. |
animate | boolean | undefined | false | Run 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. |
as | string | Component | undefined | undefined | A root tag of your own (`RouterLink`, Inertia’s `Link`). Stronger than `href`. |
href | string | undefined | undefined | A metric as a link: a move to the details. |
clickable | boolean | undefined | false | A metric as a button: the whole tile is interactive. |
locale | string | undefined | undefined | The BCP-47 locale of the formatting. Unset — it is taken from the i18n adapter; with no adapter either, the manual separators work. |
precision | number | undefined | undefined | The number of digits after the decimal point. |
prefix | string | undefined | undefined | An addition before the value (a currency, a sign). |
suffix | string | undefined | undefined | An addition after the value (a unit of measurement, `%`). |
polarity | GrDeltaPolarity | undefined | undefined | What 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. |
decimalSeparator | string | undefined | undefined | The decimal separator. Stronger than the locale; without it and without a locale — a full stop. |
groupSeparator | string | undefined | undefined | The digit-group separator. Stronger than the locale; without it and without a locale — a narrow space. |
trend | GrStatisticTrend | undefined | undefined | The direction of the dynamics — it sets the colour and the icon of the line under the value. |
trendText | string | undefined | undefined | The text of the dynamics (`+12.5% against last week`, for instance). |
animateDuration | number | undefined | 600 | The 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. |
valuerequired | string | number | — | The value of the metric. A non-numeric string is printed as it is. |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | The value instead of the `value` prop. |
icon | any | An icon before the label. |
title | any | The label of the metric instead of the `title` prop. |
prefix | any | An addition before the value: a currency sign, an arrow. |
suffix | any | An addition after the value: a unit of measurement, a per cent. |
trend | any | The dynamics under the value instead of the built-in `GrDelta`. |
Events
| Event | Type | Description |
|---|---|---|
click | [event: MouseEvent] | — |
Examples 5
KPI row
- Revenue
- $1,284,500
- Active users
- 18,342
- Conversion
- 4.8%
<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%
<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
- Refunds
- 97
- Average check
- 5,980.40₽
<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
<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%
- Time to first response
- 2 h 15 min
<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>