mortalkiller / filament-skeleton-loading
Skeleton loading states for Filament: lazy widgets, deferred tables and lazy sections show the shape of what is coming instead of a grey box or a spinner.
Package info
github.com/mortalkiller/filament-skeleton-loading
pkg:composer/mortalkiller/filament-skeleton-loading
Requires
- php: ^8.3
- filament/filament: ^4.15.2 || ^5.10.2
- illuminate/support: ^12.0 || ^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- mortalkiller/filament-package-standard: ^2.0
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^4.1
- pestphp/pest-plugin-laravel: ^4.0
- pestphp/pest-plugin-livewire: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Filament Skeleton Loading
Skeleton loading states for Filament 4 and 5 panels. Lazy widgets, lazy sections and deferred tables show the shape of what is coming (stat tiles, a chart, table rows) instead of a grey box or a spinner.
Plain CSS and Blade, no theme build, no new loading behaviour: the package only changes what Filament draws while it waits.
Why
Filament already loads widgets lazily and can defer tables, but while they load it shows an empty pulsing box or a spinner. The page then jumps when the real content arrives, and nothing tells the user what is about to appear. Fixing that per widget means overriding placeholder() and writing skeleton markup and CSS in every application.
This package keeps Filament's own loading mechanisms (placeholder(), deferLoading(), deferred schemas, the loading-section view) and only changes what is drawn while waiting, in the panels that register the plugin.
Non-goals: skeletons for modals, and progress for actions such as saving, exporting or uploading, which keep Filament's native indicators. Tables keep Filament's own behaviour while they search, sort, filter or paginate, unless a table opts in to Filament's loadingSkeleton().
Documentation
Full documentation, guides and API reference:
https://docs.pedromonteiro.dev/filament-skeleton-loading/
Contents
- Why
- Documentation
- Features
- Screenshots
- Version compatibility
- Installation
- Usage
- Page loader
- Configuration
- Accessibility
- Testing and contributing
- Changelog
- Roadmap
- Security
- Support this project
- Credits and license
Features
- Typed widget skeletons with the
HasSkeletonPlaceholdertrait, detected from the widget class:- stats overview: one tile per column;
- chart: bars, a line or a ring (doughnut, pie, polar area), with the chart's real heading;
- table widget: header and rows.
- Relation managers with the same trait get a table skeleton.
- Generic skeleton for every other lazy widget, relation manager or Livewire component, with no code change.
- Deferred tables draw skeleton rows instead of Filament's spinner, following the visible columns or the card grid where Filament exposes them.
- Native table skeletons: tables that use Filament's
loadingSkeleton()get the package's colours and radius while they update. - Database notifications panel: three placeholder notifications instead of a spinner.
- Lazy loaded schemas (
Section::make()->schema(...)->lazyLoaded()): form, infolist or list placeholders instead of a spinner, chosen from the page or withskeletonPlaceholder(). - Search skeletons (opt-in): skeleton lines while searchable selects and the global search wait for results.
- Page loader (opt-in): a full-screen loader between pages with your brand symbol, shown only when the next page is slow.
- Rounded corners per panel with
rounded(), from the theme's radius tokens or exact pixels. - Shimmer that can be turned off, and that always stands still for
prefers-reduced-motion. - Light and dark mode; translations for
en,pt,pt_PTandpt_BR.
Screenshots
| Light | Dark |
|---|---|
![]() |
![]() |
The page loader with the default symbol:
For comparison, the same dashboard without the plugin, with Filament's own loading states:
The screenshots come from the Workbench demo panel. Run it locally with composer browser:prepare && composer serve and open /admin, or /plain for the same panel without the plugin.
Version compatibility
| Package | PHP | Laravel | Filament |
|---|---|---|---|
| 1.x | 8.3, 8.4, 8.5 | 12, 13 | 4.15.2+ and 5.10.2+ |
CI tests the lowest and latest dependency sets of each Filament major.
Installation
composer require mortalkiller/filament-skeleton-loading
Register the plugin on each panel that should use the skeletons. Panels without it keep Filament's own loading states:
use MortalKiller\FilamentSkeletonLoading\SkeletonLoadingPlugin; $panel->plugin(SkeletonLoadingPlugin::make());
The stylesheet is published with Filament's assets (php artisan filament:assets, which filament:upgrade already runs after composer install).
Usage
Widgets
Add the trait to a lazy widget or relation manager (both are lazy by default):
use Filament\Widgets\ChartWidget; use MortalKiller\FilamentSkeletonLoading\Concerns\HasSkeletonPlaceholder; class RevenueChart extends ChartWidget { use HasSkeletonPlaceholder; // ... }
Fine-tune with properties:
protected ?int $skeletonItems = 3; // stat tiles (stats widgets) protected ?int $skeletonRows = 8; // rows (table widgets and relation managers)
Widgets without the trait still get the generic skeleton.
Tables
Use Filament's own deferred loading on a table:
return $table ->deferLoading() ->columns([/* ... */]);
or defer every table of the panel from the plugin (a closure decides per table):
use Filament\Resources\Pages\ListRecords; use Filament\Tables\Table; SkeletonLoadingPlugin::make() ->deferTables(fn (Table $table): bool => $table->getLivewire() instanceof ListRecords);
Page loader
Off by default. When on, leaving for another page of the panel (a link or a plain form) shows a full-screen loader, but only if the next page takes longer than the delay, so fast pages never flash. Livewire requests (actions, filters, polling) and links with a download attribute never show it. When the browser stays on the page after all (a cancelled "leave page?" prompt, a link that downloads a file), a click or Escape hides it, and it hides itself after 10 seconds.
SkeletonLoadingPlugin::make() ->pageLoader() // ->pageLoader(false) turns it off ->pageLoaderSymbol(fn () => view('brand.loader-symbol')) // optional; three dots otherwise ->pageLoaderDelay(300) // milliseconds before it shows ->pageLoaderLabel('Loading…'); // optional; translated "Loading…" otherwise
The symbol is any markup (a string, an Htmlable, a view or a closure returning one). It is rendered as trusted HTML, so pass your own markup, never user input. Give the elements that should come in one after the other the class fsl-loader-piece (up to six, animated in document order); they take the panel's primary colour, white in dark mode:
<svg viewBox="0 0 120 96" width="64" height="52" fill="currentColor"> <rect class="fsl-loader-piece" x="0" y="0" width="120" height="20" rx="10"/> <rect class="fsl-loader-piece" x="0" y="38" width="88" height="20" rx="10"/> </svg>
Configuration
Global settings, in .env (or publish the config with php artisan vendor:publish --tag=filament-skeleton-loading-config):
| Setting | Env | Default | Effect |
|---|---|---|---|
enabled |
SKELETON_LOADING_ENABLED |
true |
Off: Filament's own grey placeholder and spinner everywhere, the trait included, and no page loader. |
defer_tables |
SKELETON_LOADING_DEFER_TABLES |
false |
Every table of the panels using the plugin loads after the page, with skeleton rows. |
Plugin methods, per panel:
| Method | Default | Effect |
|---|---|---|
shimmer(bool $condition = true) |
on | Light sweeping across the skeleton. |
pageLoader(bool $condition = true) |
off | Full-screen loader between pages (see Page loader). |
pageLoaderSymbol(string|Htmlable|Closure $symbol) |
three dots | Brand symbol of the page loader. |
pageLoaderDelay(int $milliseconds) |
300 |
How long a page may take before the loader shows. |
pageLoaderLabel(string|Closure $label) |
"Loading…" (en, pt, pt_PT, pt_BR) | Text under the symbol, also read by screen readers. |
deferTables(bool|Closure $condition = true) |
the defer_tables setting |
Calls deferLoading() on the panel's tables; a closure decides per table. |
rounded(int|string $size = 'md') |
the theme's radius | Corner radius of the skeletons: none, xs to 4xl, full, or pixels (8, '2.5px'). |
selectSearchSkeletons(bool $condition = true) |
off | Skeleton lines while a searchable select waits for its options. |
globalSearchSkeletons(bool $condition = true) |
off | Skeleton lines under the global search field while it waits for results. |
Set the corner radius for skeleton bars and deferred table cells per panel.
$panel->plugin( SkeletonLoadingPlugin::make()->rounded(8), );
Use a named size such as sm, lg or none, or an exact radius such as 8 (pixels) or '2.5px'.
Without an explicit setting, the radius follows the theme's --fsl-bone-radius, then --radius-md, then 6px.
Accessibility
Every skeleton is a role="status" region with aria-busy="true" and Filament's translated "Loading" label for screen readers; the shapes themselves are aria-hidden. Animations stop for users who prefer reduced motion.
Testing and contributing
In your application's Livewire tests, load a deferred table before asserting on its records:
Livewire::test(ListOrders::class)->loadTable()->assertCanSeeTableRecords($orders);
Turning defer_tables on in an app with Livewire table tests means calling ->loadTable() in those tests; pin the setting in phpunit.xml to keep tests independent of .env.
To work on the package itself:
composer install composer check # Pint, Larastan and Pest npm install npm run test:browser # Playwright against the Workbench panel
See CONTRIBUTING.md for the development flow.
Changelog
Release notes are published with each GitHub Release.
Roadmap
See docs/roadmap.md.
Security
Report vulnerabilities privately using SECURITY.md.
Support this project
If this package saves you time, consider supporting its development:
https://buymeacoffee.com/mortalkiller
Credits and license
MIT. See LICENSE.md.




