Odometer Easy plugin screenshot
Dark mode ready
Multilingual support
Supports v5.x

Odometer Easy

Community

Animated counters for Filament v3, v4 and v5 — tables, infolists and stats widgets — the easiest way possible: install, register the plugin and use it.

Tags: Widget Tables Infolist Entry
Supported versions:
5.x 4.x 3.x
Guilherme Ferro avatar Author: Guilherme Ferro

Package health

Beta

Automated checks of this plugin's Composer package

89 / 100
Security 85
Maintenance 90
Ecosystem 100
15 checks
Third-party plugin. This is built by the community, not the Filament team. Filament does not review, endorse, or vet the security of plugins outside the filament/ namespace. Review the source and install at your own risk. Found malware or an unresolved security issue the author won't address? Report it .
Powered by Plumb Last scanned 1 month ago

Documentation

filament-odometer-easy

Latest Version Total Downloads License

🇺🇸 English · 🇧🇷 Português

Animated counters for Filament v3, v4 and v5 — tables, infolists and stats widgets — the easiest way possible: install, register the plugin and use it.

It's the same effect as the "Items found" counter on the official filamentphp.com/plugins page, ready for your dashboards and real-time metrics.

#🎬 Demo

OdometerStat on the dashboard — with poll, the counters re-animate on their own every time the value updates:

OdometerStat with polling

OdometerColumn in tables — animates on load, on sorting and on page change:

OdometerColumn in a table

OdometerEntry in infolists and OdometerNavigationBadge in menus:

OdometerEntry in an infolist and navigation badge

Badge visible on a collapsed sidebar — Filament hides the badge as soon as the menu collapses; with ->badgeOnCollapsedSidebar() it floats on the icon's corner instead, in the same shape Filament already uses for the table filters button:

Light Dark
Badge on a collapsed sidebar Badge on a collapsed sidebar, dark mode

#Components

Component Extends Usage
OdometerColumn TextColumn Table columns
OdometerEntry TextEntry Infolist entries
OdometerStat Stat Counts in StatsOverviewWidget
OdometerNavigationBadge Navigation badge (getNavigationBadge()) — can stay visible on a collapsed sidebar
FilamentOdometerEasy facade Any custom view/blade

All of them inherit 100% of the base component API (sortable, searchable, label, description, color etc.) — only the value becomes animated.

#Animation engines (drivers)

The package ships two engines and you pick one via config or fluently on the plugin:

#number-flow — default ⭐

The number-flow web component (used by the Filament website itself):

  • Zero dependencies — no jQuery, no CDN; the bundle (~16 KB) ships with the package
  • Animates from 0 on first render — shows 0 and, after a configurable delay, animates up to the value
  • Re-animates on every update — perfect with Livewire, poll() and real-time dashboards
  • Native formatting via Intl.NumberFormat — currency, decimals and locale (pt-BR1.000,00)
  • Accessible — respects prefers-reduced-motion
  • ✅ Actively maintained

#odometer — secondary

The classic odometer.js effect via gsferro/laravel-odometer-easy (installed as a dependency):

  • 🎨 7 visual themes: default, car, digital, minimal, plaza, slot-machine, train-station
  • ⚠️ Depends on jQuery (the plugin injects it automatically into the panels' <head>)
  • ⚠️ Animates only on first render (does not re-animate when the value updates)

#Compatibility

Filament Support Notes
5.x
4.x
3.x (3.2+)

The same package version covers all three — Composer resolves it against your project's Filament version. Requires PHP 8.2+.

#Installation

composer require gsferro/filament-odometer-easy
php artisan filament:assets

Register the plugin in your panel:

use Gsferro\FilamentOdometerEasy\FilamentOdometerEasyPlugin;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->plugin(FilamentOdometerEasyPlugin::make());
}

Done. ✨ No npm, no publishing views, no asset configuration — the number-flow driver just works.

[!TIP] Most apps already run filament:assets automatically on post-autoload-dump (via filament:upgrade). In that case, composer require is all you need.

#Usage

#Table column

use Gsferro\FilamentOdometerEasy\Tables\Columns\OdometerColumn;

OdometerColumn::make('total_sales')
    ->label('Total sales')
    ->sortable(),

#Infolist entry

use Gsferro\FilamentOdometerEasy\Infolists\Components\OdometerEntry;

OdometerEntry::make('total_sales')
    ->label('Total sales'),

#Stat (StatsOverviewWidget)

use Gsferro\FilamentOdometerEasy\Widgets\OdometerStat;

protected function getStats(): array
{
    return [
        OdometerStat::make('Total sales', Sale::count())
            ->description('Last 30 days')
            ->descriptionIcon('heroicon-m-arrow-trending-up')
            ->color('success'),
    ];
}

[!TIP] Combine it with ->poll('10s') on the widget: with the number-flow driver, the counter re-animates on every value update. 📈

#Navigation badge (panel menu)

use Gsferro\FilamentOdometerEasy\Navigation\OdometerNavigationBadge;

// on a Resource (or Page)
public static function getNavigationBadge(): ?string
{
    return OdometerNavigationBadge::make(static::getModel()::count());
}

// or on a custom NavigationItem
NavigationItem::make('Sales')
    ->badge(fn (): string => OdometerNavigationBadge::make(Sale::count())),

Filament's navigation API only accepts string (HTML is escaped), so the component wraps the value with an invisible marker and the package's JS swaps the badge text for an animated <number-flow>. Formatting uses the global number-flow config (locales, format, delay, duration).

[!NOTE] Available only on the number-flow driver. On the odometer driver, the value is displayed as plain text, without animation.

#Keeping the badge visible on a collapsed sidebar

With ->sidebarCollapsibleOnDesktop() on the panel, Filament hides the badge as soon as the sidebar collapses: the container carries x-show="$store.sidebar.isOpen" and gets an inline display:none. The count disappears exactly in the mode where only the icon is left — the mode with the least information.

The option lives on the plugin, inside your Panel Provider — and it depends on the panel having a collapsible sidebar, which is the state it covers:

// app/Providers/Filament/AdminPanelProvider.php

public function panel(Panel $panel): Panel
{
    return $panel
        ->id('admin')
        ->path('admin')
        // 1. prerequisite: without a collapsible sidebar there is no state to fix
        ->sidebarCollapsibleOnDesktop()
        ->plugin(
            FilamentOdometerEasyPlugin::make()
                // 2. keeps the badge visible once it collapses
                ->badgeOnCollapsedSidebar()
        );
}

[!WARNING] Without ->sidebarCollapsibleOnDesktop() (or ->sidebarFullyCollapsibleOnDesktop()) on the panel, the option does nothing: Filament never enters the collapsed state, and the CSS only applies to .fi-main-sidebar:not(.fi-sidebar-open).

The badge then floats on the icon's top-right corner, with a solid background cutting through the border — exactly the shape Filament already uses for the table filters trigger. With the sidebar open, nothing changes: the native layout (inline badge, right of the label) is preserved.

  • ✅ CSS only — no published Filament view, no JavaScript
  • ✅ Inlined in <head> (~600 bytes) — does not require php artisan filament:assets
  • ✅ Works on both drivers: it positions Filament's badge, not the counter
  • ✅ Light and dark mode, plus RTL

[!IMPORTANT] Opt-in. Off by default, so bumping the version never changes the menu of someone who did not ask for it. To enable it from the config file: 'badge-on-collapsed-sidebar' => true.

[!TIP] There are only ~16px of room to the right of the item, so counts with 5+ digits may lose 1-2px at the edge (.fi-sidebar-nav is overflow-x:hidden). If that is your case, use compact notation: ->format(['notation' => 'compact']) — 12,345 becomes 12K.

#In any view (facade)

use Gsferro\FilamentOdometerEasy\Facades\FilamentOdometerEasy;

// configured driver (number-flow by default)
FilamentOdometerEasy::render(1500);

// forcing a driver on the spot
FilamentOdometerEasy::renderNumberFlow(1500, format: ['style' => 'currency', 'currency' => 'BRL']);
FilamentOdometerEasy::renderOdometer(1500, format: '(.ddd),dd', class: 'h3');

#Formatting

The ->format() method is available on every component and accepts the active driver's format. On the default number-flow driver, pass an array of Intl.NumberFormat options — formatting (symbol, separators, fraction digits) is applied by the browser and animated digit by digit.

#Currency ($, R$, €…)

By default the counter shows the plain number. To display the currency symbol, pass a format with style: currency:

OdometerStat::make('Approved amount (ongoing projects)', $approved)
    ->format(['style' => 'currency', 'currency' => 'BRL']),

[!TIP] Combine it with ->locales('pt-BR') on the plugin (or in the config) to get R$ 1.234,56 — without a locale, the user's browser decides the separators.

#Ready-to-use recipes (number-flow driver)

Result (en-US) ->format([...])
$1,234.56 (currency) ['style' => 'currency', 'currency' => 'USD']
$1,235 (currency, no cents) ['style' => 'currency', 'currency' => 'USD', 'maximumFractionDigits' => 0]
R$ 1.234,56 / €1,234.56 ['style' => 'currency', 'currency' => 'BRL'] / 'EUR'
12.5% (percentage) ['style' => 'percent', 'minimumFractionDigits' => 1]
1,234.50 (fixed decimals) ['minimumFractionDigits' => 2, 'maximumFractionDigits' => 2]
1.2M (compact notation) ['notation' => 'compact']
1,234 km (units) ['style' => 'unit', 'unit' => 'kilometer']
+1,234 (always show sign) ['signDisplay' => 'always']
1234 (no grouping) ['useGrouping' => false]

[!WARNING] style: percent multiplies the value by 100 — pass 0.125 to display 12.5%.

#Dynamic format (Closure)

->format() also accepts a Closure. On columns and entries, Filament injects $record/$state:

OdometerColumn::make('balance')
    ->format(fn (Account $record): array => [
        'style' => 'currency',
        'currency' => $record->currency, // BRL, USD, EUR...
    ]),

#Animation speed

Every component accepts ->duration() (number-flow driver; the higher, the slower):

OdometerStat::make('Revenue', $total)
    ->duration(2000), // counts in slow motion ✨

#odometer driver

On the secondary driver, ->format() takes the odometer.js data-format string:

OdometerColumn::make('revenue')
    ->format('(.ddd),dd'),

#Where to set each number-flow option

Option Per component Global (plugin/config) What it does
format ->format([...]) ->format([...]) Intl.NumberFormat options (currency, percent, decimals…)
duration ->duration(ms) ->duration(ms) Animation speed (default ~900ms)
locales ->locales('pt-BR') Language/separators (1.000,00)
delay ->delay(ms) Wait before the initial 0 → value animation (default 500ms)

The per-component value always wins over the global one. The FilamentOdometerEasy::renderNumberFlow() facade accepts every option per call (format, delay, duration).

References: Intl.NumberFormat options · odometer format.

#Configuration

#Fluently, right on the plugin

FilamentOdometerEasyPlugin::make()
    ->locales('pt-BR')                                      // number-flow: 1.000,00
    ->format(['style' => 'currency', 'currency' => 'BRL'])  // global default
    ->delay(500)                                            // ms before the initial animation (0 → value)
    ->duration(1500)                                        // animation speed in ms (default ~900ms)
    ->badgeOnCollapsedSidebar(),                            // menu badge stays visible when the sidebar collapses

To use the classic engine:

FilamentOdometerEasyPlugin::make()
    ->driver('odometer')
    ->theme('digital')          // default, car, digital, minimal, plaza, slot-machine, train-station
    ->format('(.ddd),dd')       // default data-format
    ->jquery(enabled: false),   // when your application already loads jQuery

#Or through the config file

php artisan vendor:publish --tag="filament-odometer-easy-config"
return [
    // number-flow (default) | odometer
    'driver' => 'number-flow',

    // keeps the navigation badge visible when the desktop sidebar is collapsed
    'badge-on-collapsed-sidebar' => false,

    'number-flow' => [
        'locales' => null,  // e.g. 'pt-BR'; null uses the browser locale
        'format' => null,   // e.g. ['style' => 'currency', 'currency' => 'BRL']
        'delay' => 500,     // ms before the initial animation: shows 0 and animates up to the value
        'duration' => null, // animation speed in ms; null uses the default (~900ms)
    ],

    'odometer' => [
        'theme' => 'default',
        'format' => null,  // e.g. '(.ddd),dd'; null uses the default (pt-BR: 1.000,00)

        'jquery' => [
            'enabled' => true,
            'src' => 'https://code.jquery.com/jquery-4.0.0.min.js',
            'integrity' => 'sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=',
        ],
    ],
];

#How it works under the hood

  • number-flow: the package ships the <number-flow> web component bundled (resources/dist/filament-odometer-easy.js, registered as an ES module via FilamentAsset), the same one used on filamentphp.com/plugins. The Blade view renders the element with data-value/data-format/data-locales and the bundle initializes it: shows 0, waits for the delay and animates up to the value. A MutationObserver tracks data-value changes made by Livewire's morph (poll, refresh) and re-animates from the current value to the new one — without relying on x-init, which does not run again when Livewire preserves the element.
  • Navigation badge: getNavigationBadge() and NavigationItem::badge() are typed as ?string and Blade escapes the content, so returning HTML is not possible. OdometerNavigationBadge::make() wraps the value with U+2060 (word joiner, invisible); the bundle detects the marker inside .fi-badge-label, swaps the text for a <number-flow> and uses the global config exposed on window.filamentOdometerEasy via render hook. When Livewire re-renders the badge, the animation starts from the previous value (data-start).
  • Collapsed sidebar badge: Filament has no prop, config or per-item render hook for this, and publishing the sidebar.item view would freeze 150 lines of Blade on every upgrade. The package injects a <style> into <head> via render hook, scoped to .fi-main-sidebar:not(.fi-sidebar-open) — Filament itself already exposes the sidebar state as a class (fi-sidebar-open) and the menu item is already position: relative. The display: flex !important is what beats the inline declaration Alpine's x-show writes. It only applies inside @media (width >= 64rem), the same breakpoint as Filament's Alpine sidebar store.
  • odometer: the assets (css theme, odometer.js, odometer-easy.js) are served straight from the gsferro/odometer-easy vendor via FilamentAsset, and jQuery is injected via render hook into the panels' <head>.
  • Switching drivers selects which assets get registered — never both at the same time.

#Development

The number-flow bundle only needs to be rebuilt if you change resources/js/index.js:

npm install
npm run build

#Tests

composer test

#See also

gsferro/filament-stat-plus-easy — stat cards with a corner icon and a colored accent border, for Filament v3, v4 and v5. StatPlus extends this package's OdometerStat, so the animated counter comes along — plus a matching loading skeleton.

#Changelog

Please see CHANGELOG for more information on what has changed recently.

#Contributing

Please see CONTRIBUTING for details.

#Security Vulnerabilities

Please review our security policy on how to report security vulnerabilities.

#Credits

#License

The MIT License (MIT). Please see License File for more information.