Skip to content
LarawellUi

<x-widget.price-roll>

Price roll

Odometer-style price that rolls its digits when data-value changes, with an optional change badge (since a previous price), trend colours that can be inverted or switched off, and before/after slots for your own markup. Formats with Intl on both server and client. Works inside Livewire components: a render with a new value rolls to it.

php artisan larawell:add price-roll
Also adds
Nothing else. It stands alone.
Needs PHP extension
intl

Usage

Livewire

In a Livewire component, pass the price from a property: each render rolls the digits to the new one and updates the change badge, on wire:poll too. A price roll that arrives with a render sets itself up.

Blade
<div wire:poll.5s="refreshQuote">
    <x-widget.price-roll :value="$quote->price" :previous="$quote->open" currency="USD" label="BTC" live class="text-4xl font-semibold" />
</div>

Examples

Live price

Change data-value from anywhere and the digits roll to the new price, flashing green or red; here the script beside this does it. live announces changes to screen readers.

Price: $64,210.50
Show code
Blade
<div class="flex flex-col items-start gap-4">
    <x-widget.price-roll id="btc-price" :value="64210.50" currency="USD" label="Price" live class="text-4xl font-semibold" />
    <div class="flex gap-2">
        <button type="button" class="bg-field text-foreground rounded-full px-4 py-2 text-sm font-medium" data-price-change="1375.25" aria-controls="btc-price">Price up</button>
        <button type="button" class="bg-field text-foreground rounded-full px-4 py-2 text-sm font-medium" data-price-change="-842.75" aria-controls="btc-price">Price down</button>
    </div>
</div>
JavaScript, in your own JS file
// In a real app the new price comes from your server (polling, a websocket); setting data-value is all it takes.
document.addEventListener('click', (event) => {
    const button = event.target.closest('[data-price-change]');
    const price = button && document.getElementById(button.getAttribute('aria-controls'));
    if (price) {
        price.dataset.value = Math.max(0, Number(price.dataset.value) + Number(button.dataset.priceChange)).toFixed(2);
    }
});

With change

previous is where the price started (today's open, say); the badge shows the change since then and follows the price as it rolls. change="percent" (the default), "amount" or "both". Screen readers hear it with the price: "Price: $65,585.75, up 4.38%".

Ether: $3,218.40, up $115.65 (+3.73%)
Show code
Blade
<div class="flex flex-col items-start gap-4">
    <x-widget.price-roll id="eth-price" :value="3218.40" :previous="3102.75" currency="USD" locale="en-US" label="Ether" change="both" live class="text-4xl font-semibold" />
    <div class="flex gap-2">
        <button type="button" class="bg-field text-foreground rounded-full px-4 py-2 text-sm font-medium" data-price-step="84.60" aria-controls="eth-price">Price up</button>
        <button type="button" class="bg-field text-foreground rounded-full px-4 py-2 text-sm font-medium" data-price-step="-131.25" aria-controls="eth-price">Price down</button>
    </div>
</div>
JavaScript, in your own JS file
// A new price from your server is all it takes: set data-value, and the digits and the badge follow.
document.addEventListener('click', (event) => {
    const button = event.target.closest('[data-price-step]');
    const price = button && document.getElementById(button.getAttribute('aria-controls'));
    if (price) {
        price.dataset.value = Math.max(0, Number(price.dataset.value) + Number(button.dataset.priceStep)).toFixed(2);
    }
});

Trend colors

Up is good by default (green). trend-colors="inverse" is for values where up is bad, like a delivery fee or a wait; "none" drops the colours, for a total that is neither good nor bad news.

Portfolio

Portfolio: $12,480, up 3.14%

Delivery fee

Delivery fee: $6.50, up $1.51

Cart total

Cart total: $84.20, up $4.30
Show code
Blade
<div class="grid gap-6 sm:grid-cols-3">
    <div>
        <p class="text-foreground/75 mb-1 text-sm">Portfolio</p>
        <x-widget.price-roll id="trend-default" :value="12480" :previous="12100" currency="USD" locale="en-US" :decimals="0" label="Portfolio" class="text-2xl font-semibold" />
    </div>
    <div>
        <p class="text-foreground/75 mb-1 text-sm">Delivery fee</p>
        <x-widget.price-roll id="trend-inverse" :value="6.50" :previous="4.99" currency="USD" locale="en-US" label="Delivery fee" trend-colors="inverse" change="amount" class="text-2xl font-semibold" />
    </div>
    <div>
        <p class="text-foreground/75 mb-1 text-sm">Cart total</p>
        <x-widget.price-roll id="trend-none" :value="84.20" :previous="79.90" currency="USD" locale="en-US" label="Cart total" trend-colors="none" change="amount" class="text-2xl font-semibold" />
    </div>
    <button type="button" class="bg-field text-foreground justify-self-start rounded-full px-4 py-2 text-sm font-medium sm:col-span-3" data-raise-all="trend-default trend-inverse trend-none">Raise all three</button>
</div>
JavaScript, in your own JS file
// Raises each listed price by 5%, to show how each colour mode treats the same move.
document.addEventListener('click', (event) => {
    const button = event.target.closest('[data-raise-all]');
    if (!button) {
        return;
    }
    for (const id of button.dataset.raiseAll.split(' ')) {
        const price = document.getElementById(id);
        price.dataset.value = (Number(price.dataset.value) * 1.05).toFixed(2);
    }
});

Slots

The before and after slots hold your own markup around the rolling number: "from", "/month", a unit or an icon. They aren't rolled, and screen readers read them as written, around the price. Here the plan toggle rolls between the monthly and the yearly price.

Team plan

Team plan: $24 from per seat / month

Yearly billing saves 20%.

Show code
Blade
<div class="border-line bg-surface w-full max-w-sm rounded-3xl border p-6">
    <div class="flex items-center justify-between gap-4">
        <p class="text-lg font-semibold">Team plan</p>
        <div class="bg-field inline-flex rounded-full p-1 text-sm" role="group" aria-label="Billing period">
            <button type="button" class="aria-pressed:bg-surface aria-pressed:shadow-sm rounded-full px-3 py-1 font-medium" aria-pressed="true" data-billing="24" aria-controls="team-price">Monthly</button>
            <button type="button" class="aria-pressed:bg-surface aria-pressed:shadow-sm rounded-full px-3 py-1 font-medium" aria-pressed="false" data-billing="19" aria-controls="team-price">Yearly</button>
        </div>
    </div>
    <x-widget.price-roll id="team-price" :value="24" currency="USD" locale="en-US" :decimals="0" label="Team plan" trend-colors="none" live class="mt-4 text-5xl font-bold">
        <x-slot:before class="text-foreground/75 text-base font-medium">from</x-slot:before>
        <x-slot:after class="text-foreground/75 text-base font-medium">per seat / month</x-slot:after>
    </x-widget.price-roll>
    <p class="text-foreground/75 mt-2 text-sm">Yearly billing saves 20%.</p>
</div>
JavaScript, in your own JS file
// The billing toggle: press one, and the price rolls to its amount.
document.addEventListener('click', (event) => {
    const button = event.target.closest('[data-billing]');
    const price = button && document.getElementById(button.getAttribute('aria-controls'));
    if (!price) {
        return;
    }
    button.parentElement.querySelectorAll('[data-billing]').forEach((other) => other.setAttribute('aria-pressed', String(other === button)));
    price.dataset.value = button.dataset.billing;
});

Sizes

Size and weight come from your own classes, so it fits a hero, a card or a sentence. The digits roll within the line they're given.

Raised so far: $1,284,930

Balance

Balance: $2,408.17

Your next invoice is Next invoice: $49.00 , due on 1 October.

Show code
Blade
<div class="grid gap-8">
    <x-widget.price-roll :value="1284930" currency="USD" locale="en-US" :decimals="0" label="Raised so far" class="text-6xl font-bold tracking-tight" />
    <div class="border-line bg-surface max-w-xs rounded-2xl border p-4">
        <p class="text-foreground/75 text-sm">Balance</p>
        <x-widget.price-roll :value="2408.17" currency="USD" locale="en-US" label="Balance" class="text-2xl font-semibold" />
    </div>
    <p class="text-foreground/75 max-w-prose">Your next invoice is <x-widget.price-roll :value="49" currency="USD" locale="en-US" label="Next invoice" class="text-foreground font-semibold" />, due on 1 October.</p>
</div>

Formats

Formatting follows the locale, on the server and in the browser. currency=false formats a plain number. Without currency it uses config('app.currency').

₹12,34,567.50 999 € 1,284
Show code
Blade
<div class="flex flex-wrap items-baseline gap-8">
    <x-widget.price-roll :value="1234567.5" currency="INR" locale="en-IN" class="text-2xl" />
    <x-widget.price-roll :value="999" currency="EUR" locale="de-DE" :decimals="0" class="text-2xl" />
    <x-widget.price-roll :value="1284" :currency="false" class="text-2xl" />
</div>

Props

Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.

<x-widget.price-roll>

Prop Default Description
value 0
currency null
locale null
decimals null
label null
live false
previous null
change 'percent'
trend-colors 'default'

Source

What larawell:add price-roll writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from the components it also adds , and the theme and base CSS.

resources/views/components/widget/price-roll/index.blade.php Show
index.blade.php
@props([
    'value' => 0,
    'currency' => null,
    'locale' => null,
    'decimals' => null,
    'label' => null,
    'live' => false,
    'previous' => null,
    'change' => 'percent',
    'trendColors' => 'default',
])

@php
    $value = is_numeric($value) ? (float) $value : 0.0;
    $locale ??= str_replace('_', '-', app()->getLocale());
    // Each character of the price is its own flex item, so the row must run in the price's own direction, not the
    // page's: "$1,234" stays in order on an Arabic page, and an Arabic-script price reads right to left anywhere.
    $priceDirection = in_array(strtolower(explode('-', $locale)[0]), ['ar', 'he', 'fa', 'ur', 'ps', 'yi', 'dv', 'ug', 'ckb', 'sd'], true) ? 'rtl' : 'ltr';
    $currency ??= config('app.currency', 'USD');

    // Same formatting as Intl.NumberFormat in resources/js/widget/price-roll, so the JS picks up the
    // server's markup as-is. currency=false formats a plain number. Needs PHP's intl extension.
    $formatter = new \NumberFormatter($locale, $currency ? \NumberFormatter::CURRENCY : \NumberFormatter::DECIMAL);
    if ($decimals !== null) {
        $formatter->setAttribute(\NumberFormatter::MIN_FRACTION_DIGITS, (int) $decimals);
        $formatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, (int) $decimals);
    }
    $text = $currency ? $formatter->formatCurrency($value, $currency) : $formatter->format($value);
    $chars = mb_str_split((string) $text);

    // previous="…": a badge with the change since then ("+4.38%", "+$2,750.50", or both), which the script keeps
    // up to date as the price moves. Worked out the same way as resources/js/widget/price-roll, so they agree.
    $previous = is_numeric($previous) ? (float) $previous : null;
    // A typo fails loudly, naming the values that work, instead of quietly rendering something else.
    if (! in_array($change, ['percent', 'amount', 'both'], true)) {
        throw new \InvalidArgumentException("Unknown change [{$change}] for <x-widget.price-roll>. Use one of: percent, amount, both.");
    }
    // default: up is good (green). inverse: up is bad, for a cost or a wait. none: no colours at all.
    if (! in_array($trendColors, ['default', 'inverse', 'none'], true)) {
        throw new \InvalidArgumentException("Unknown trend-colors [{$trendColors}] for <x-widget.price-roll>. Use one of: default, inverse, none.");
    }
    $changeText = null;
    $direction = 'flat';
    $spokenChange = '';
    if ($previous !== null) {
        $delta = round($value - $previous, 10);
        $direction = $delta > 0 ? 'up' : ($delta < 0 ? 'down' : 'flat');
        $sign = $delta > 0 ? '+' : ($delta < 0 ? '-' : '');
        $amount = $sign.($currency ? $formatter->formatCurrency(abs($delta), $currency) : $formatter->format(abs($delta)));
        $percentFormatter = new \NumberFormatter($locale, \NumberFormatter::PERCENT);
        $percentFormatter->setAttribute(\NumberFormatter::MIN_FRACTION_DIGITS, 2);
        $percentFormatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, 2);
        // No percentage from a zero baseline; the amount says it instead.
        $percent = $previous != 0.0 ? $sign.$percentFormatter->format(abs($delta / $previous)) : null;
        $changeText = match (true) {
            $change === 'amount' || $percent === null => $amount,
            $change === 'both' => "{$amount} ({$percent})",
            default => $percent,
        };
        $spokenChange = ', '.($direction === 'flat' ? 'unchanged' : $direction.' '.ltrim($changeText, '+-'));
    }
    // Good or bad, not up or down: the colour follows trend-colors, for the badge and the flash while digits roll.
    $tone = static fn (string $direction): string => $trendColors === 'none' || $direction === 'flat'
        ? 'neutral'
        : ((($direction === 'up') !== ($trendColors === 'inverse')) ? 'good' : 'bad');
    $digitCount = count(array_filter($chars, static fn (string $char): bool => ctype_digit($char)));
    $position = $digitCount;
@endphp

{{--
    Odometer-style price. Each digit is a one-line window onto a 0–9 column, shifted up by the digit
    (translate: 0 calc(var(--d) * -1lh)), so changing --d rolls it. Change the price by setting
    data-value (el.dataset.value = 1299.5); the JS formats it, rolls the digits and flashes up/down.
    Screen readers get the plain formatted price instead of the columns; `live` announces changes.
--}}
<span
    data-price-roll
    data-value="{{ $value }}"
    data-locale="{{ $locale }}"
    @if ($currency) data-currency="{{ $currency }}" @endif
    @if ($label) data-label="{{ $label }}" @endif
    @if ($decimals !== null) data-decimals="{{ (int) $decimals }}" @endif
    @if ($previous !== null) data-previous="{{ $previous }}" data-change="{{ $change }}" @endif
    data-trend-colors="{{ $trendColors }}"
    {{-- data-flash is set by the script for a moment after each change: good or bad, per trend-colors. --}}
    {{ $attributes->class(['text-foreground data-[flash=bad]:text-error data-[flash=good]:text-success inline-flex items-baseline gap-x-1.5 tabular-nums transition-colors duration-500']) }}
>
    <span data-price-roll-text class="sr-only" @if ($live) aria-live="polite" @endif>{{ $label ? $label.': ' : '' }}{{ $text }}{{ $spokenChange }}</span>
    {{-- Your own markup before the number: "from", a currency toggle, an icon. Not rolled, and read as written. --}}
    @isset($before)
        <span data-price-roll-before {{ $before->attributes }}>{{ $before }}</span>
    @endisset
    {{-- wire:ignore: the digits are the script's to roll. A Livewire render changes data-value above, which rolls them; redrawing
         them too would put each column straight at its new digit, so they'd jump instead. --}}
    <span data-price-roll-visual wire:ignore aria-hidden="true" dir="{{ $priceDirection }}" class="inline-flex">
        @foreach ($chars as $char)
            @if (ctype_digit($char))
                {{-- The mask fades the window's top and bottom edge, so digits slide in and out rather than being sliced. --}}
                {{-- --i counts from the right, so higher places start rolling a little later, like a real odometer. --}}
                <span data-price-roll-digit class="inline-block h-[1lh] overflow-hidden [mask-image:linear-gradient(transparent,#000_12%,#000_88%,transparent)]">
                    {{-- --d and --i are classes from the ranges at the end of base.css, not style=""; the script sets them inline from then on. --}}
                    <span class="[--d:{{ $char }}] [--i:{{ max(0, min(30, --$position)) }}] block [translate:0_calc(var(--d)*-1lh)] transition-[translate] duration-700 ease-[cubic-bezier(0.2,0.8,0.2,1)] [transition-delay:calc(var(--i)*40ms)] motion-reduce:transition-none">
                        @for ($n = 0; $n <= 9; $n++)
                            <span class="block text-center">{{ $n }}</span>
                        @endfor
                    </span>
                </span>
            @else
                <span data-price-roll-char class="whitespace-pre">{{ $char }}</span>
            @endif
        @endforeach
    </span>
    @if ($changeText !== null)
        {{-- Hidden from screen readers, which get the change with the price (above). The arrow backs up the colour. --}}
        <span
            data-price-roll-change
            aria-hidden="true"
            data-direction="{{ $direction }}"
            data-tone="{{ $tone($direction) }}"
            dir="{{ $priceDirection }}"
            class="data-[tone=good]:bg-success/10 data-[tone=good]:text-[color-mix(in_oklab,var(--color-success)_75%,var(--color-foreground))] data-[tone=bad]:bg-error/10 data-[tone=bad]:text-error data-[tone=neutral]:bg-field data-[tone=neutral]:text-foreground/75 self-center rounded-full px-2 py-0.5 text-[max(0.75rem,0.4em)] leading-none font-medium whitespace-nowrap transition-colors"
        ><span data-price-roll-arrow>{{ ['up' => '▲', 'down' => '▼'][$direction] ?? '' }}</span> <span data-price-roll-change-text>{{ $changeText }}</span></span>
    @endif
    {{-- Your own markup after it: "/month", "per share", a unit. --}}
    @isset($after)
        <span data-price-roll-after {{ $after->attributes }}>{{ $after }}</span>
    @endisset
</span><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
resources/js/widget/price-roll/index.js Show
index.js
// Drives <x-widget.price-roll>. Setting data-value re-formats the price and rolls each digit column
// to its new --d. Columns are matched from the right (units stay units), so going from 999 to 1,000
// rolls the existing three and adds a new column on the left that rolls up from 0.

const TREND_MS = 1200;

function digitColumn() {
    const frame = document.createElement('span');
    frame.dataset.priceRollDigit = '';
    frame.className = 'inline-block h-[1lh] overflow-hidden [mask-image:linear-gradient(transparent,#000_12%,#000_88%,transparent)]';
    const column = document.createElement('span');
    column.className = 'block transition-[translate] duration-700 ease-[cubic-bezier(0.2,0.8,0.2,1)] [transition-delay:calc(var(--i)*40ms)] motion-reduce:transition-none';
    column.style.setProperty('--d', '0');
    column.style.translate = '0 calc(var(--d) * -1lh)';
    for (let n = 0; n <= 9; n++) {
        column.append(Object.assign(document.createElement('span'), { className: 'block text-center', textContent: String(n) }));
    }
    frame.append(column);

    return frame;
}

function staticChar(char) {
    return Object.assign(document.createElement('span'), { className: 'whitespace-pre', textContent: char });
}

// Kept here rather than in a data- attribute, which a Livewire render would take away: the same one set up twice would
// roll every change twice.
const ready = new WeakSet();

function initPriceRoll(root) {
    if (ready.has(root)) {
        return;
    }
    ready.add(root);

    const visual = root.querySelector('[data-price-roll-visual]');
    const text = root.querySelector('[data-price-roll-text]');
    const label = root.dataset.label ? `${root.dataset.label}: ` : '';
    const decimals = root.dataset.decimals === undefined ? {} : { minimumFractionDigits: Number(root.dataset.decimals), maximumFractionDigits: Number(root.dataset.decimals) };
    const format = new Intl.NumberFormat(root.dataset.locale || undefined, root.dataset.currency
        ? { style: 'currency', currency: root.dataset.currency, ...decimals }
        : decimals).format;
    let current = Number(root.dataset.value) || 0;
    let trendTimer = null;

    // previous="…": the change badge, formatted exactly as the Blade view does (sign, then the absolute value).
    const badge = root.querySelector('[data-price-roll-change]');
    const previous = root.dataset.previous === undefined ? null : Number(root.dataset.previous);
    const percent = new Intl.NumberFormat(root.dataset.locale || undefined, { style: 'percent', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format;
    const colors = root.dataset.trendColors ?? 'default';
    // Good or bad rather than up or down, as in the view: inverse flips it, none has no colour.
    const tone = (direction) => (colors === 'none' || direction === 'flat' ? 'neutral' : (direction === 'up') !== (colors === 'inverse') ? 'good' : 'bad');

    function changeSince(value) {
        const delta = Math.round((value - previous) * 1e10) / 1e10;
        const direction = delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat';
        const sign = delta > 0 ? '+' : delta < 0 ? '-' : '';
        const amount = sign + format(Math.abs(delta));
        const pct = previous !== 0 ? sign + percent(Math.abs(delta / previous)) : null;
        const mode = root.dataset.change;
        const text = mode === 'amount' || pct === null ? amount : mode === 'both' ? `${amount} (${pct})` : pct;
        const spoken = direction === 'flat' ? 'unchanged' : `${direction} ${text.replace(/^[+-]/, '')}`;

        return { direction, text, spoken };
    }

    function update() {
        const next = Number(root.dataset.value);
        if (!Number.isFinite(next) || next === current) {
            return;
        }
        const chars = [...format(next)];
        const old = [...visual.children];

        // Walk both strings from the right, reusing a digit column wherever a digit meets a digit.
        const built = chars.map((char, index) => {
            const match = old[old.length - (chars.length - index)];
            const isDigit = char >= '0' && char <= '9';
            if (isDigit && match?.hasAttribute('data-price-roll-digit')) {
                return match;
            }
            if (!isDigit) {
                return match && !match.hasAttribute('data-price-roll-digit') && match.textContent === char ? match : staticChar(char);
            }
            return digitColumn();
        });
        visual.replaceChildren(...built);

        // Flush layout before setting the new digits: re-inserted columns (replaceChildren moves them) and
        // new ones (at 0) would otherwise take the new --d without a transition, i.e. jump instead of roll.
        visual.getBoundingClientRect();
        let place = built.filter((el) => el.hasAttribute('data-price-roll-digit')).length;
        built.forEach((el, index) => {
            if (el.hasAttribute('data-price-roll-digit')) {
                const column = el.firstElementChild;
                column.style.setProperty('--i', String(--place));
                column.style.setProperty('--d', chars[index]);
            }
        });

        let spoken = '';
        if (badge && previous !== null) {
            const change = changeSince(next);
            badge.dataset.direction = change.direction;
            badge.dataset.tone = tone(change.direction);
            badge.querySelector('[data-price-roll-arrow]').textContent = { up: '▲', down: '▼' }[change.direction] ?? '';
            badge.querySelector('[data-price-roll-change-text]').textContent = change.text;
            spoken = `, ${change.spoken}`;
        }
        text.textContent = label + format(next) + spoken;
        // A moment of colour on the digits for this move, good or bad per trend-colors (none: no flash).
        const flash = tone(next > current ? 'up' : 'down');
        if (flash === 'neutral') {
            delete root.dataset.flash;
        } else {
            root.dataset.flash = flash;
        }
        clearTimeout(trendTimer);
        trendTimer = setTimeout(() => delete root.dataset.flash, TREND_MS);
        current = next;
    }

    new MutationObserver(update).observe(root, { attributes: true, attributeFilter: ['data-value'] });
}

export function initPriceRolls(scope = document) {
    scope.querySelectorAll('[data-price-roll]').forEach(initPriceRoll);
}

initPriceRolls();

// Price rolls added to the page later (a Livewire render or wire:navigate, fetched HTML) set themselves up, like the
// select and date pickers. One already set up rolls from its new data-value instead.
new MutationObserver((records) => {
    for (const node of records.flatMap((record) => [...record.addedNodes])) {
        if (node instanceof Element) {
            (node.matches('[data-price-roll]') ? [node] : node.querySelectorAll('[data-price-roll]')).forEach(initPriceRoll);
        }
    }
}).observe(document.documentElement, { childList: true, subtree: true });