Sticky (Pinned) Columns
Sticky columns stay frozen on the left while the rest of the table scrolls horizontally.
Dev-declared default
Mark a column sticky with ->sticky():
TextColumn::make('name')->sticky();
This column is pinned for everyone out of the box.
User-controlled pinning
Enable ->stickableColumns() on the table. A Pin columns dropdown appears in the toolbar (before the column-manager trigger).
- Checkboxes draft which columns are pinned.
- Select all / Deselect all shortcuts are included.
- Changes apply only when the user clicks Apply (the button is disabled when nothing changed).
- Closing the panel without Apply keeps the draft in memory for that page session.
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')->sticky(), // seeds initial selection
TextColumn::make('email'),
TextColumn::make('created_at'),
])
->dragReorderableColumns()
->stickableColumns();
}
Any ->sticky() calls seed the initial selection. Once a user applies their own choice, that selection is remembered.
Unpin from the header
A pinned column that the user pinned themselves carries a clickable pin icon in its header. Clicking it unpins that column straight away — no panel, no Apply — and the new selection persists like any other change.
Columns declared with ->sticky() show the same pin, but it is not clickable: those are the developer's defaults and toggleColumnSticky() refuses to remove them, so the header offers no control that would do nothing.
| Column | Pin icon | Click to unpin |
|---|---|---|
->sticky() | Yes | No — pinned for everyone |
| Pinned by the user | Yes | Yes |
| Not pinned | No | — |
Customize the trigger
Customize the toolbar button with ->stickyManagerTriggerAction(), mirroring Filament's columnManagerTriggerAction():
use Filament\Actions\Action;
public function table(Table $table): Table
{
return $table
->columns([...])
->stickableColumns()
->stickyManagerTriggerAction(fn (Action $action) => $action
->label('Pinned columns')
->icon('heroicon-o-map-pin')
->tooltip('Choose which columns stay visible'));
}
The default trigger is a small gray Filament button labeled Pin columns (translatable via resources/lang).
Behaviour notes
- Pinned columns are left-pin only in the current version.
- Pinned headers show a small pin indicator, clickable to unpin on user-pinned columns; resize handles work on sticky columns (offsets refresh after drag).
- Sticky columns are excluded from drag-to-reorder; columns cannot be dropped before a sticky column.
- Header and body cells use opaque backgrounds so horizontally scrolled content does not show through.
Style sticky backgrounds
Pinned cells need opaque backgrounds. By default they resolve to Filament's own table surface variables, in both light and dark mode — nothing to configure:
| Variable | Used for | Default (light) | Default (dark) |
|---|---|---|---|
--resized-sticky-header-bg | Pinned header cells | var(--gray-50) | color-mix(in oklab, var(--color-white) 5%, var(--gray-900)) |
--resized-sticky-cell-bg | Pinned body cells (default row) | var(--color-white) | var(--gray-900) |
--resized-sticky-cell-bg-emphasis | Pinned body cells (striped, hover, selected) | var(--gray-50) | color-mix(in oklab, var(--color-white) 5%, var(--gray-900)) |
A custom Filament theme that changes --gray-* or --color-white is picked up automatically.
Striped tables
On a table using Filament's ->striped(), the tinted rows are painted by --resized-sticky-cell-bg-emphasis — the same variable that covers hovered and selected rows. --resized-sticky-cell-bg only applies to plain rows.
public function table(Table $table): Table
{
return $table
->striped()
->stickableColumns();
}
:root {
/* plain rows */
--resized-sticky-cell-bg: var(--color-white);
/* striped, hovered, and selected rows */
--resized-sticky-cell-bg-emphasis: var(--gray-50);
}
Filament's own stripe is a translucent tint layered over the row (color-mix(..., transparent) in dark mode). A pinned cell cannot be translucent — the columns scrolling underneath would show through — so give this variable an opaque colour that matches the stripe. The defaults already do this by compositing the tint over the table surface.
To use your own colours, declare the variables in your panel's theme — resources/css/filament/{panel}/theme.css. Light mode goes on :root, dark mode on .dark (Filament toggles that class on <html>):
/* resources/css/filament/admin/theme.css */
@import '../../../../vendor/filament/filament/resources/css/theme.css';
@source '../../../../app/Filament/**/*';
:root {
--resized-sticky-header-bg: var(--gray-100);
--resized-sticky-cell-bg: var(--gray-50);
--resized-sticky-cell-bg-emphasis: var(--gray-100);
}
.dark {
--resized-sticky-header-bg: var(--gray-800);
--resized-sticky-cell-bg: var(--gray-800);
--resized-sticky-cell-bg-emphasis: var(--gray-700);
}
Then rebuild the theme — npm run build, or npm run dev while working. The theme is a Vite entry, so editing the file alone changes nothing until it is compiled.
The defaults live in the var() fallback chain, not in a competing declaration, so your values win regardless of stylesheet order. Narrower scopes work the same way when you only want some tables restyled:
/* one resource — the page wrapper carries .fi-resource-{slug} */
.fi-resource-users {
--resized-sticky-cell-bg: #eef2ff;
}
/* every table container in the panel */
.fi-ta-ctn {
--resized-sticky-cell-bg: #eef2ff;
}
Unset variables keep the Filament defaults, so overriding only light mode (or only the header) is fine. Use opaque colours: a translucent value lets the scrolled columns show through the pinned cell.
Persistence
The user's pinned selection is persisted per user (session + database) alongside widths and order — no extra migration beyond the table_settings table.