Number flow
Number flow renders a single <span> that tweens smoothly between numbers, ideal for KPI tiles, counters and live metrics. On mount it counts up from zero, and whenever value changes it rolls from the number currently on screen to the new one. Every frame is rendered through Intl.NumberFormat, so thousands separators, currency, percentages and decimals all animate cleanly, and tabular figures keep the value from shifting horizontally as digits change.
TIP
The tween respects prefers-reduced-motion: when reduced motion is requested the value swaps straight to its target instead of tweening. The final, settled value is always exposed as the accessible label, so screen readers read the real number rather than the frames streaming past.
Props
value: number
The number that is displayed. Whenever this value changes, the display tweens from the value on screen to the new one.
animate-on-mount?: boolean
Count up from zero to value when the component first mounts. Turn this off to render the final value immediately.
Default: true
duration?: number
The tween duration, in milliseconds.
Default: 800
easing?: "linear" | "ease-in" | "ease-out" | "ease-in-out" | cubic-bezier(x1, y1, x2, y2) | (t: number) => number
The easing curve the rAF tween samples each frame. Accepts a JavaScript easing function, a cubic-bezier() string or one of the named keywords. The default matches the --swift-out curve from @flux-ui/components.
Default: cubic-bezier(0.55, 0, 0.1, 1)
format?: Intl.NumberFormatOptions
Intl.NumberFormat options used to render every frame, for thousands separators, currency, percentages and decimals. When omitted the value is rendered as a whole number.
locale?: string
The locale passed to Intl.NumberFormat. Defaults to the runtime locale.
Examples
Basic
Bind a reactive value and the display tweens every time it changes.
<template>
<FluxFlex
align="center"
direction="horizontal"
:gap="15">
<FluxSecondaryButton
icon-leading="minus"
@click="step(-2500)"/>
<FluxVisualNumberFlow
:value="value"
style="min-width: 120px; font-size: 33px; font-weight: 700; text-align: center;"/>
<FluxSecondaryButton
icon-leading="plus"
@click="step(2500)"/>
</FluxFlex>
</template>
<script
lang="ts"
setup>
import { FluxFlex, FluxSecondaryButton } from '@flux-ui/components';
import { FluxVisualNumberFlow } from '@flux-ui/visuals';
import { ref } from 'vue';
const value = ref(25000);
function step(delta: number): void {
value.value = Math.max(0, value.value + delta);
}
</script>Easing
Swap the easing curve through the easing prop: a named keyword, a cubic-bezier() string or your own function. Each row tweens to the same value so the curves are easy to compare.
<template>
<FluxFlex
align="start"
direction="vertical"
:gap="18">
<FluxFlex
v-for="row in rows"
:key="row.label"
align="baseline"
direction="horizontal"
:gap="12">
<span style="width: 168px; color: var(--gray-500); font-size: 13px; font-weight: 500;">{{ row.label }}</span>
<FluxVisualNumberFlow
:easing="row.easing"
:value="value"
style="font-size: 24px; font-weight: 700;"/>
</FluxFlex>
<FluxSecondaryButton
icon-leading="rotate"
label="Recount"
@click="recount"/>
</FluxFlex>
</template>
<script
lang="ts"
setup>
import { FluxFlex, FluxSecondaryButton } from '@flux-ui/components';
import { FluxVisualNumberFlow } from '@flux-ui/visuals';
import { ref } from 'vue';
// Each row tweens to the same value with a different easing so the curves are
// easy to compare: the default swift-out, a linear ramp, a cubic-bezier() that
// overshoots before settling and a custom ease-out-cubic function.
const rows = [
{label: 'Swift-out (default)', easing: undefined},
{label: 'Linear', easing: 'linear'},
{label: 'Overshoot', easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)'},
{label: 'Ease-out cubic', easing: (t: number) => 1 - (1 - t) ** 3}
] as const;
const value = ref(75000);
function recount(): void {
value.value = 20000 + Math.floor(Math.random() * 80000);
}
</script>KPI tile
A live dashboard stat: the value counts up on mount and rolls each time the underlying data updates, formatted as currency.
<template>
<FluxPane style="width: 261px;">
<FluxPaneBody>
<FluxFlex
align="start"
direction="vertical"
:gap="3">
<span style="color: var(--gray-500); font-size: 13px; font-weight: 500;">Monthly revenue</span>
<FluxVisualNumberFlow
:format="format"
:value="value"
style="font-size: 33px; font-weight: 700;"/>
</FluxFlex>
</FluxPaneBody>
</FluxPane>
</template>
<script
lang="ts"
setup>
import { FluxFlex, FluxPane, FluxPaneBody } from '@flux-ui/components';
import { FluxVisualNumberFlow } from '@flux-ui/visuals';
import { onBeforeUnmount, onMounted, ref } from 'vue';
const format: Intl.NumberFormatOptions = {style: 'currency', currency: 'USD', maximumFractionDigits: 0};
const value = ref(84500);
let timer: number;
function tick(): void {
value.value += Math.floor(Math.random() * 900);
}
onMounted(() => {
timer = window.setInterval(tick, 2500);
});
onBeforeUnmount(() => window.clearInterval(timer));
</script>Formatting
Pass Intl.NumberFormat options through format to render currency, percentages and decimals, each tweening in its own format.
<template>
<FluxFlex
align="start"
direction="vertical"
:gap="18">
<FluxFlex
v-for="item in items"
:key="item.label"
align="baseline"
direction="horizontal"
:gap="12">
<span style="width: 120px; color: var(--gray-500); font-size: 13px; font-weight: 500;">{{ item.label }}</span>
<FluxVisualNumberFlow
:format="item.format"
:value="item.value"
style="font-size: 24px; font-weight: 700;"/>
</FluxFlex>
<FluxSecondaryButton
icon-leading="rotate"
label="New values"
@click="randomize"/>
</FluxFlex>
</template>
<script
lang="ts"
setup>
import { FluxFlex, FluxSecondaryButton } from '@flux-ui/components';
import { FluxVisualNumberFlow } from '@flux-ui/visuals';
import { reactive } from 'vue';
type Metric = {
label: string;
value: number;
format: Intl.NumberFormatOptions;
};
const items = reactive<Metric[]>([
{label: 'Revenue', value: 128400, format: {style: 'currency', currency: 'EUR', maximumFractionDigits: 0}},
{label: 'Conversion', value: 0.184, format: {style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 1}},
{label: 'Avg. rating', value: 4.6, format: {minimumFractionDigits: 1, maximumFractionDigits: 1}}
]);
function randomize(): void {
items[0].value = 80000 + Math.floor(Math.random() * 100000);
items[1].value = Math.random() * 0.4;
items[2].value = 3 + Math.random() * 2;
}
</script>