GrDelta
Shows a signed value with its sign, tone and arrow inline in a sentence.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- a signed number stands inside a sentence — in the description of an operation, in a table cell, in the caption under a chart;
- the sign decides the colour — growth, decline and “unchanged” are seen before the quantity itself is read;
- growth can be bad —
polarity="negative-good"for cost, response time and churn; - the quantity may be missing — “no data” and “zero” are drawn differently.
When to take something else
| Need | Take |
|---|---|
| A large metric as a tile, with a label and a trend | GrStatistic |
| A number as a status or a label | GrBadge |
| A share of a whole as a bar or a ring | GrProgressBar / GrProgressCircle |
| A series of values over time | GrSparkline |
| A “property → value” pair | GrDescriptionList |
Zero is neutral, `null` is not zero
Two states that are not expressed by two colours, and both have already led to bugs in applications:
- zero — “unchanged”. Neither a success nor an error: the tone is neutral with
any
polarity. A condition of the formvalue < 0 ? danger : successpaints a zero movement green, like an income; null— there is no quantity at all. It is printed as a dash (emptyText), with no tone, no arrow and no affixes:$—reads as “zero dollars”.
The choice of tone lives in a pure function of its own and is covered by tests
without mounting — it is exactly that switch that used to spread through an
application in copies.
The function is public: deltaTone(value, polarity) and deltaDirection(value)
are given away by the package. They are needed where the markup of a delta does not
fit but the rule is the same — the tone of GrStatistic and
GrBadge in the tiles of a report. Rewritten in an application, it
diverges from this one at the very first edit — which is what happened with the zero
painted green like an income.
Polarity inverts the tone but not the sign
<GrDelta :value="-15" suffix="%" polarity="negative-good" />
Cost fell by 15 % — that is a success, and the quantity is green. But it is still
−15 %: the sign belongs to the number, not to the judgement. The arrow follows the
sign as well — downwards, because the quantity decreased.
polarity="none" removes the tone entirely: for a balance or an offset the sign says
nothing about quality.
The sign is set by `Intl`, not by string concatenation
showSign switches on signDisplay: 'exceptZero' in the formatting rather than
appending a '+' to a ready string. The difference is not stylistic: concatenation
has already broken GrStatistic — the string '+1,234' stops being a number, and
the digit groups are lost at the next step.
The locale comes from the i18n adapter unless it is set with a prop.
The sign stands before the currency
Together with prefix the sign moves into a node of its own before the affix:
+$0,0280, not $+0,0280. The sign belongs to the quantity as a whole rather than
to the number after the currency symbol, and the second order is accepted in no
typographic tradition.
The invariant above is not weakened in the process: the sign is still set by Intl,
the component merely takes it out of formatToParts — the string is not
concatenated and the digit groups are not reassembled. Without prefix there is
nowhere to take it out to, and the markup stays as it was.
There is deliberately no way to configure the position of the sign. A separate subtlety is RTL: there the sign is preceded by an invisible direction mark, which is what turns it around relative to the digits; it moves together with the sign, because it is the sign it governs.
The affixes themselves are drawn by GrValue — the primitive shared
with GrStatistic. Their styling and the --gr-value-* tokens come from there too:
a currency on the left is set like a number, a unit on the right is dimmed. A currency
on the right (+1 000 ₽) is obtained by changing the default of the suffix, and the
recipe is on the page of the primitive.
The type size comes from the line
The default step (md) does not set font-size at all: the quantity is set in the
type size of the line it stands in. Inside a heading it is large, in a caption under a
chart it is small, in a table cell it is exactly like the rest of the text of the
cell — and all of that without a single prop.
<h2 class="text-[length:var(--gr-text-3xl)]">
Revenue for March <GrDelta :value="8.4" :precision="1" suffix="%" show-arrow />
</h2>
The edges of the ladder stay explicit and take the control scale — xs 12 px, sm
13 px, lg 16 px. They are needed in the other case: when the quantity stands not
inside a sentence but in a row with controls, and has to match them rather than the
surrounding text.
The arrow is set in em and grows together with the number — it has no step of its
own. When overriding the type size from the outside, override the pair:
the component does not touch the line height, and a half-measure leaves the line with
someone else’s leading.
The arrow is decorative
showArrow draws the direction but marks the icon aria-hidden: the direction is
already announced by the sign, and there is no point duplicating it for a screen
reader. There is no arrow by default — in a line of text it more often adds noise than
helps.
Limits
The component does not compute the delta: it accepts a ready one. “Compared to what” — the period, the base value, the filters — is known by the application, and dragging that into a presentational component means dragging its data model in there too. Percentages, comparisons of periods and a sparkline are outside as well.
Playground 8
Loading…
<GrDelta />Install
npm i @feugene/granularityImport
import { GrDelta } from '@feugene/granularity/components/GrDelta'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
emptyText | string | undefined | undefined | What to print a missing quantity with. |
locale | string | undefined | undefined | The locale of the formatting. Unset — the locale of the i18n adapter. |
precision | number | undefined | undefined | Digits after the decimal point. Unset — as the locale gives it. |
prefix | string | undefined | undefined | An affix before the number: a currency, a unit. |
suffix | string | undefined | undefined | An affix after the number: `%`, `ms`. |
polarity | GrDeltaPolarity | undefined | undefined | What counts as good. For revenue growth is a success, for cost and response time it is the opposite; without this the component would lie in half of the cases. |
showSign | boolean | undefined | undefined | Put a `+` on positive values. The minus is set by `Intl` itself. |
showArrow | boolean | undefined | undefined | An arrow of direction. Decorative: the direction is already in the sign. |
valuerequired | number | null | — | The quantity. `null` means there is none; that is not a zero. |
Examples 3
Signed value inside a sentence
Balance change: +$1,284.50
Margin: -$12.50
Unchanged: $0.00
Not measured: —
<script setup lang="ts">
import { GrDelta } from '@feugene/granularity'
</script>
<template>
<div class="grid gap-2 text-sm">
<p>
Balance change: <GrDelta :value="1284.5" :precision="2" prefix="$" />
</p>
<p>
Margin: <GrDelta :value="-12.5" :precision="2" prefix="$" />
</p>
<!-- Ноль нейтрален: «не изменилось» — третье состояние, и двумя цветами
его не выразить. -->
<p>
Unchanged: <GrDelta :value="0" :precision="2" prefix="$" />
</p>
<!-- `null` — величины нет вовсе. Это не ноль, поэтому ни тона, ни приписок. -->
<p>
Not measured: <GrDelta :value="null" prefix="$" />
</p>
</div>
</template>Polarity: when growth is bad
Revenue: +8.4% — polarity="positive-good"
Churn: +2.1% — polarity="negative-good"
Response time: -15.0% — polarity="negative-good"
Balance offset: -3.2% — polarity="none"
<script setup lang="ts">
import { GrDelta } from '@feugene/granularity'
const rows = [
{ metric: 'Revenue', value: 8.4, polarity: 'positive-good' as const },
{ metric: 'Churn', value: 2.1, polarity: 'negative-good' as const },
{ metric: 'Response time', value: -15, polarity: 'negative-good' as const },
{ metric: 'Balance offset', value: -3.2, polarity: 'none' as const },
]
</script>
<template>
<!--
Полярность инвертирует тон, но не знак: «−15 %» времени отклика зелёное,
потому что стало быстрее, — и всё ещё минус. Без этого компонент врал бы
в половине случаев.
-->
<div class="grid gap-2 text-sm">
<p v-for="row in rows" :key="row.metric">
{{ row.metric }}:
<GrDelta :value="row.value" :precision="1" suffix="%" :polarity="row.polarity" show-arrow />
<!-- Не `opacity-*`: прозрачность разбавляет выверенный на AA токен и роняет контраст. -->
<span class="text-[var(--gr-muted-fg)]"> — polarity="{{ row.polarity }}"</span>
</p>
</div>
</template>The value takes the type size of its line
<script setup lang="ts">
import { GrDelta } from '@feugene/granularity'
</script>
<template>
<!--
Разметка величины во всех трёх строках одна и та же — `size` не задан
нигде. Кегль приходит от строки, поэтому стрелка и суффикс растут вместе
с числом, а не остаются 14-пиксельными внутри заголовка.
-->
<div class="grid gap-4">
<div class="text-[length:var(--gr-text-3xl)] leading-[var(--gr-leading-3xl)] font-600">
Выручка за март
<GrDelta :value="8.4" :precision="1" suffix="%" show-arrow />
</div>
<div class="text-[length:var(--gr-text-xl)] leading-[var(--gr-leading-xl)]">
Средний чек
<GrDelta :value="-3.2" :precision="1" suffix="%" show-arrow />
</div>
<div class="text-[length:var(--gr-control-text-sm)]">
Возвраты
<GrDelta :value="1.6" :precision="1" suffix="%" polarity="negative-good" show-arrow />
</div>
<!--
Явная ступень нужна там, где величина стоит не в предложении, а в ряду
с контролами: тогда она обязана совпасть с ними, а не с текстом вокруг.
-->
<div class="flex items-center gap-2 text-[length:var(--gr-text-xl)]">
<span>В ряду с контролами:</span>
<GrDelta :value="8.4" :precision="1" suffix="%" size="sm" />
</div>
</div>
</template>