awcodes/filament-sticky-header
Makes Filament panel page headers sticky on scroll, with an optional floating theme and colored variant. Install via Composer, add the plugin to your panel provider, and import the provided CSS into your theme. Supports Filament 2.x through 5.x depending on package version.
1. Pick the right version line before you install. The plugin's compatibility matrix is the first thing to get right, because a wrong constraint is a silent waste of an afternoon:
| Filament | Package |
|---|---|
| 2.x | 1.x |
| 3.x | 2.x |
| 4.x | 3.x or 4.x |
| 4.x & 5.x | 4.x (as of v4.2.0) |
# Filament 4 or 5
composer require awcodes/filament-sticky-header:^4.0
2. You must already have a custom theme. This plugin ships only a CSS file (resources/css/plugin.css) and a small JS asset; it does not inject styling for you. If you don't have a theme yet, create one first (php artisan make:filament-theme), register it on the panel, and confirm the panel renders with it before touching this package.
// app/Providers/Filament/AdminPanelProvider.php
->viteTheme('resources/css/filament/admin/theme.css')
3. Import the plugin CSS into that theme file.
/* resources/css/filament/admin/theme.css */
@import '../../../../vendor/awcodes/filament-sticky-header/resources/css/plugin.css';
@import 'tailwindcss'; /* keep your existing imports where they are */
Count the ../ from the theme file's real location — the vendor path in the README assumes resources/css/filament/admin/theme.css.
4. Register the plugin on the panel and rebuild assets.
use Awcodes\StickyHeader\StickyHeaderPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->viteTheme('resources/css/filament/admin/theme.css')
->plugins([
StickyHeaderPlugin::make(),
]);
}
npm run build
php artisan optimize:clear
First use case to validate with: a resource EditRecord page for a long form, or a ListRecords page with many rows. Scroll down — the title, breadcrumb, and the Save/Delete/Create actions should stay reachable. If nothing changes, you have a theme/CSS problem, not a plugin problem (see Gotchas).
Where to look first when you need to customize: vendor/awcodes/filament-sticky-header/resources/css/plugin.css. It is short and readable — the class names there tell you exactly which Filament header selectors are being targeted, which is what you'll need for overrides and for debugging layout collisions.
Baseline: enable once, treat it as panel-level chrome. This is not a per-page plugin; you enable it per panel and then subtract where it hurts. The common production shape:
StickyHeaderPlugin::make()
->floating()
->colored()
->stickOnListPages(false)
->disabledOn([
App\Filament\Pages\DashboardBoard::class, // kanban / full-bleed canvas
App\Filament\Pages\ReportViewer::class, // embedded iframe report
])
Per-user preference with closures is the headline feature. Both floating() and colored() accept Closure(): bool. Wire them to a user setting, but guard the unauthenticated case — login, 2FA, and password-reset screens render the panel too:
StickyHeaderPlugin::make()
->floating(fn (): bool => (bool) (auth()->user()?->prefers_floating_header ?? false))
->colored(fn (): bool => (bool) (auth()->user()?->prefers_colored_header ?? false))
->stickOnListPages(fn (): bool => (bool) (auth()->user()?->prefers_sticky_lists ?? true));
Keep these closures cheap — no queries, no service calls. Read from the already-loaded auth()->user() model or a cached settings object, because they run on every panel render.
Multi-panel apps. Register the plugin in each panel you want it in, and branch the closures on the current panel so the admin and the customer portal can behave differently:
->floating(fn (): bool => Filament::getCurrentPanel()?->getId() === 'admin')
Choosing between stickOnListPages(false) and disabledOn():
| Need | Use |
|---|---|
| Tables need the vertical space / sticky table headers conflict | stickOnListPages(false) |
| One specific custom page (dashboard canvas, embedded chart, wizard) | disabledOn([Page::class]) |
| Runtime list (e.g. only pages flagged in config) | disabledOn(fn () => [...]) |
| Per-resource opt-out | disabledOn() with the resource's page classes — it takes page class strings, not slugs |
Compose with your own theme overrides, not with forks. Put any tuning in the same theme.css, after the @import, so you keep one build and one source of truth:
@import '../../../../vendor/awcodes/filament-sticky-header/resources/css/plugin.css';
@import 'tailwindcss';
/* Give phones their screen back */
@media (max-width: 640px) {
.fi-header.fi-sticky { position: static; }
}
/* Anchors and in-page jumps shouldn't land under the header */
html { scroll-padding-top: 5rem; }
/* Don't let a sticky bar wreck printed reports */
@media print {
.fi-header.fi-sticky { position: static; }
}
(Verify the exact selector against plugin.css in the vendor directory before shipping — that file is the contract.)
Workflow pairing with long forms. Sticky headers pay off most on EditRecord pages with a Save action in the header. If you also have a sticky table or sticky column elsewhere, decide per page which one wins rather than letting both fight — that's what disabledOn() is for.
QA checklist for every upgrade. Manual, but short: (1) scroll a long list page, (2) scroll a long edit form and click Save — title must update, (3) click between pages with wire:navigate and confirm the header doesn't double-render or stick wrongly, (4) open a modal from a header action, (5) trigger a notification while scrolled down, (6) check a phone viewport, (7) open a page in disabledOn().
Upgrade routine. composer update alone is not enough — the CSS lives in your compiled theme, so your pipeline must run npm ci && npm run build after every composer change. Watch the changelog specifically for CSS/JS fixes (v2.0.6's wire:navigate re-init, v3.1.0's disabledOn(), the "scope JS asset registration to panel context" fix); those are the releases that change behavior in a compiled-theme setup.
grep -c 'fi-header' public/build/assets/*.css.@import. The README's ../../../../ assumes a specific theme location. If your theme is at resources/css/theme.css, it's ../../../vendor/.... A bad path in a CSS @import is easy to miss; a Vite build warning is usually your only clue.@import ordering. CSS requires @import rules before other statements. If you paste the import at the bottom of your theme file it will be dropped.composer update, run the frontend build. On deploy, make sure the build step runs after composer, not before.auth()->user() can be null on login/2FA pages, and the closure is evaluated on panel render, not on scroll. Null-safe it and keep it side-effect free.disabledOn() wants full class names. MyPage::class, not 'my-page' or a route slug. It also only knows about Filament page classes — nested/custom resource pages need to be listed explicitly, so test them.stickOnListPages(false) only covers list pages. Table filters and sticky-column configurations on other pages can still collide; check those individually.z-index in your theme, re-test modals and notification toasts — you'll likely cover them.floating() positions the header relative to the viewport, so if you override topbar height in your theme, the header can overlap or float too high. Set offsets in the same override block, not in a scattered stylesheet.wire:navigate re-initialization. Fixed in the 2.x line, but if you add your own JS that observes or mutates .fi-header, you must re-run it on livewire:navigated too, or your tweaks silently die after the first SPA navigation.<h1> in the body won't be sticky — that's expected, not a bug.composer.json, keep your overrides in a handful of lines at the bottom of theme.css, and if the package ever stalls, vendoring plugin.css into your own theme directory is a 30-minute exit ramp.->viteTheme() → theme file contains a valid @import → npm run build succeeded → built CSS contains the plugin rules → page source includes the plugin's JS asset → php artisan optimize:clear → inspect .fi-header classes in devtools while scrolled. That order resolves nearly every report.How can I help you explore Laravel packages today?