wezlo/filament-grid-list
Swap Filament resource tables for responsive grid/card lists without rewriting your resources. Define cards via sections, closures, or Blade views while keeping search, filters, sorting, pagination, bulk actions, and record actions working out of the box.
Replace any Filament resource's default list table with a responsive card/grid layout. Define how each record card looks using structured sections, a closure, or a custom Blade view — while keeping all of Filament's search, filters, sorting, pagination, bulk actions, and record actions working out of the box.
No need to rewrite your resource. The same table() definition powers both the grid and the standard table.
default, sm, md, lg, xl, 2xl)content-visibility: auto skips rendering of offscreen cards, lazy-loaded imagescomposer require wezlo/filament-grid-list
Optionally register the plugin in your Panel Provider for global defaults:
use Wezlo\FilamentGridList\FilamentGridListPlugin;
->plugins([
FilamentGridListPlugin::make()
->gridColumns(['default' => 1, 'sm' => 2, 'lg' => 3])
->gap(4)
->recordsPerPage(12)
->recordsPerPageOptions([12, 24, 48, 96]),
])
Optionally publish the config:
php artisan vendor:publish --tag=filament-grid-list-config
The package's Blade views use Tailwind utility classes. For Tailwind to detect them during your app's build, add the package's views as a @source in your Filament custom theme CSS file (usually resources/css/filament/admin/theme.css):
@import '../../../../vendor/filament/filament/resources/css/theme.css';
@source '../../../../vendor/wezlo/filament-grid-list/resources/views/**/*';
@custom-variant dark (&:where(.dark, .dark *));
If you don't have a custom theme yet, create one:
php artisan make:filament-theme
Then rebuild assets:
npm run build
Without this step, some Tailwind utilities used in the grid view may be missing from the compiled CSS.
Add the HasGridList trait to your resource's ListRecords page and implement the gridList() method:
use Filament\Resources\Pages\ListRecords;
use Wezlo\FilamentGridList\Concerns\HasGridList;
use Wezlo\FilamentGridList\GridListConfiguration;
class ListProducts extends ListRecords
{
use HasGridList;
protected static string $resource = ProductResource::class;
public function gridList(GridListConfiguration $config): GridListConfiguration
{
return $config
->gridColumns(['default' => 1, 'sm' => 2, 'lg' => 4])
->header(fn ($record) => $record->name)
->content(fn ($record) => Str::limit($record->description, 100))
->footer(fn ($record) => '$' . number_format($record->price, 2));
}
}
That's it. The list page now renders as a card grid. Your existing table() definition continues to power filters, search, actions, and pagination.
Build cards from discrete sections. Each receives the Eloquent record and returns a string or HtmlString. All sections are optional — use any combination.
public function gridList(GridListConfiguration $config): GridListConfiguration
{
return $config
->image(fn ($record) => $record->thumbnail_url)
->header(fn ($record) => $record->name)
->badges(fn ($record) => [
['label' => $record->status->getLabel(), 'color' => $record->status->getColor()],
['label' => $record->category->name, 'color' => 'info'],
])
->content(fn ($record) => $record->short_description)
->footer(fn ($record) => '$' . number_format($record->price, 2));
}
| Section | Closure Signature | Description |
|---|---|---|
image(Closure) |
fn (Model $record): ?string |
Hero image URL at the top of the card |
header(Closure) |
fn (Model $record): ?string |
Card title text |
badges(Closure) |
fn (Model $record): array |
Array of badge definitions (see below) |
content(Closure) |
fn (Model $record): string|HtmlString|null |
Card body text or HTML |
footer(Closure) |
fn (Model $record): ?string |
Bottom section (price, date, metadata) |
Badges can be either arrays or renderable Blade components:
// Array format (recommended)
['label' => 'Active', 'color' => 'success']
// Any renderable (Blade component, HtmlString, etc.)
view('components.my-badge', ['status' => $record->status])
Available badge colors: primary, secondary, success, danger, warning, info, gray.
Return raw HTML for full control over the card body:
use Illuminate\Support\HtmlString;
$config->describeUsing(fn ($record) => new HtmlString("
<div class='p-4'>
<h3 class='font-semibold'>{$record->name}</h3>
<p class='text-sm text-gray-500'>{$record->description}</p>
<span class='text-lg font-bold'>\${$record->price}</span>
</div>
"));
Point to your own Blade view for maximum flexibility:
$config->cardView('products.grid-card');
The view receives three variables:
| Variable | Type | Description |
|---|---|---|
$record |
Model |
The Eloquent record |
$recordKey |
string |
The record's primary key |
$recordUrl |
?string |
URL the card links to (null if no link) |
{{-- resources/views/products/grid-card.blade.php --}}
<div class="p-4">
<img src="{{ $record->image_url }}" class="w-full h-48 object-cover rounded-lg" />
<h3 class="mt-3 font-bold text-gray-900 dark:text-white">{{ $record->name }}</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $record->description }}</p>
<div class="mt-3 flex items-center justify-between">
<span class="text-lg font-semibold">${{ number_format($record->price, 2) }}</span>
<span class="text-xs text-gray-400">{{ $record->created_at->diffForHumans() }}</span>
</div>
</div>
When multiple modes are configured, the first match wins:
cardView() (custom Blade view)describeUsing() (closure)header(), content(), etc.)getTableRecordTitle()$config->gridColumns(['default' => 1, 'sm' => 2, 'md' => 3, 'lg' => 4])
->gap(6); // Tailwind spacing unit (6 = 1.5rem)
Breakpoint keys follow Tailwind: default, sm, md, lg, xl, 2xl. The value is the number of columns at that breakpoint.
Set pagination on the grid config (overrides the table's defaults):
$config->recordsPerPage(24)
->recordsPerPageOptions([12, 24, 48, 96]);
Or configure it on your table() method as usual — the grid respects it:
public function table(Table $table): Table
{
return $table
->columns([...])
->defaultPaginationPageOption(12)
->paginationPageOptions([12, 24, 48, 96]);
}
All Filament pagination modes (Default, Simple, Cursor) work.
Note: The
'all'page option is excluded from defaults to prevent page hangs on large datasets. Cards usecontent-visibility: autofor offscreen rendering optimization, but loading thousands of DOM nodes can still be slow. If you need it, opt in explicitly:->recordsPerPageOptions([12, 24, 48, 'all']).
Enabled by default when the table has bulk actions. Disable per-page with:
$config->selectable(false);
By default, cards link to the view/edit page using the resource's existing record URL logic (same as clicking a table row). Override per-page with:
$config->recordUrl(fn ($record) => route('products.show', $record));
Register the plugin in your Panel Provider to set defaults for all grid list pages in that panel:
use Wezlo\FilamentGridList\FilamentGridListPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugins([
FilamentGridListPlugin::make()
->gridColumns(['default' => 1, 'sm' => 2, 'md' => 3, 'lg' => 4])
->gap(4)
->recordsPerPage(12)
->recordsPerPageOptions([12, 24, 48, 96]),
]);
}
| Method | Type | Default | Description |
|---|---|---|---|
gridColumns(array) |
array<string, int> |
['default' => 1, 'sm' => 2, 'md' => 3, 'lg' => 4] |
Responsive grid columns |
gap(int) |
int |
4 |
Tailwind spacing unit for card gap |
recordsPerPage(int) |
int |
12 |
Default records per page |
recordsPerPageOptions(array) |
array<int|string> |
[12, 24, 48, 96] |
Per-page dropdown options |
Each setting resolves through a three-level cascade:
gridList() on the ListRecords page (highest priority)FilamentGridListPlugin in the Panel Providerconfig/filament-grid-list.php (lowest priority)Page-level always wins. If not set, falls through to plugin, then config.
// config/filament-grid-list.php
return [
'grid_columns' => [
'default' => 1,
'sm' => 2,
'md' => 3,
'lg' => 4,
],
'gap' => 4,
'records_per_page' => 12,
'records_per_page_options' => [12, 24, 48, 96],
];
use App\Enums\ProductStatus;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Str;
use Wezlo\FilamentGridList\Concerns\HasGridList;
use Wezlo\FilamentGridList\GridListConfiguration;
class ListProducts extends ListRecords
{
use HasGridList;
protected static string $resource = ProductResource::class;
public function gridList(GridListConfiguration $config): GridListConfiguration
{
return $config
->gridColumns(['default' => 1, 'sm' => 2, 'md' => 3, 'xl' => 4])
->gap(6)
->recordsPerPage(24)
->recordsPerPageOptions([12, 24, 48])
->image(fn ($record) => $record->thumbnail_url)
->header(fn ($record) => $record->name)
->badges(fn ($record) => [
['label' => $record->status->getLabel(), 'color' => $record->status->getColor()],
['label' => $record->category->name, 'color' => 'info'],
])
->content(fn ($record) => Str::limit($record->description, 120))
->footer(fn ($record) => '$' . number_format($record->price, 2))
->selectable()
->recordUrl(fn ($record) => ProductResource::getUrl('view', ['record' => $record]));
}
}
The resource's table() method stays unchanged — columns, filters, search, actions, and bulk actions all carry over to the grid view automatically.
HasGridList trait overrides content() on the ListRecords page to render a custom Blade view instead of the default EmbeddedTablemakeTable() to apply pagination config from GridListConfiguration$this->getTableRecords(), which handles the full filtered/sorted/paginated query pipeline from Filament's InteractsWithTable traitfilamentTable() Alpine component for selection state managementwire:model.live.debounce on the existing tableSearch Livewire property$this->getTableFiltersForm()toggleSelectedRecord() / isRecordSelected() from the filamentTable Alpine component — same API as the standard table$action->getClone() (same pattern as the standard table view)<x-filament::pagination> with the paginator from getTableRecords()content-visibility: auto CSS so the browser skips layout/paint for offscreen cardscontent-visibility: auto — offscreen cards skip layout and paint, keeping the page responsive even with many recordsloading="lazy" so only visible images are fetched'all' by default — pagination defaults to [12, 24, 48, 96] to prevent DOM overload. Opt in to 'all' explicitly if neededwire:init="loadTable" for deferred table loadingAll card elements use fi-grid-list-* prefixed classes for targeted styling:
| Class | Element |
|---|---|
fi-grid-list |
Root container |
fi-grid-list-content |
Grid container |
fi-grid-list-card |
Individual card |
fi-grid-list-card-clickable |
Card with a link |
fi-grid-list-card-checkbox |
Selection checkbox wrapper |
fi-grid-list-card-body |
Card content wrapper |
fi-grid-list-card-image |
Image container |
fi-grid-list-card-img |
Image element |
fi-grid-list-card-content |
Text content area |
fi-grid-list-card-title |
Header/title text |
fi-grid-list-card-badges |
Badges container |
fi-grid-list-card-description |
Content/description text |
fi-grid-list-card-footer |
Footer area |
fi-grid-list-card-actions |
Record actions bar |
Override any of these in your theme CSS to customize the card appearance.
MIT
How can I help you explore Laravel packages today?