Skip to content

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.

0

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.

0

<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.

Swift-out (default)0
Linear0
Overshoot0
Ease-out cubic0

<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.

Monthly revenue$0

<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.

Revenue€0
Conversion0.0%
Avg. rating0.0

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