A client panel had an orders table with eleven columns. On a 27-inch monitor it was fine. On a phone it scrolled sideways forever, and because the order reference was the first column, you had to drag the table back to the left every time to work out which row you were looking at.
The usual fixes are all bad. Hide six columns and lose the data. Shrink the font to 10px. Build a separate mobile view and maintain two tables that drift apart. Filament v5.2.0 added a first-party answer — stackedOnMobile() — and it is not widely known, partly because "stacked rows" does not sound like the thing that fixes a horizontal scroll.
Here is the whole job: turning it on is one line, and the four things it does not do for you.
Turn on stacked rows#
stackedOnMobile() is a method on the table, not on individual columns. Below the mobile breakpoint, each row renders as a card with the column label above its value; above it, you get the normal table. Your column definitions do not change at all.
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('reference')
->searchable()
->sortable(),
TextColumn::make('customer.name')
->searchable(),
TextColumn::make('status')
->badge(),
TextColumn::make('total')
->money('GBP')
->sortable(),
])
->stackedOnMobile();
}
This is a CSS-first change. The same markup is restyled at the breakpoint — nothing is re-rendered server-side and no separate component is involved, so searching, filtering, row actions and bulk selection all keep working. Filament also surfaces a sort dropdown at the top of the table on mobile, because the header row that normally carries the sort controls is gone, and the bulk-select checkbox moves into the header area.
You need Filament v5.2.0 or later. If you are still on v4, this is one of the smaller reasons to move, but the step-by-step upgrade from v4 to v5 is worth doing anyway. Two early patches matter here: v5.2.1 fixed text alignment when a column wrapper is a button, and v5.2.2 fixed stackedOnMobile() with grouped, collapsible and summarised tables. Do not pin to v5.2.0 exactly.
To opt a single table out after enabling it globally, pass false:
->stackedOnMobile(false)
Decide between stackedOnMobile and a Split layout#
stackedOnMobile() takes no breakpoint argument. The switch point is baked into Filament's CSS, and that is deliberate — it is the mobile breakpoint, not a knob. If the default is right for you, stop here and move on to the columns.
It is often not right. Tables with long text columns stack badly at tablet width too, and stackedOnMobile() will still be rendering a cramped horizontal table at 820px. When you need to choose the breakpoint, drop to the layout components instead:
use Filament\Support\Enums\FontWeight;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\Layout\Split;
use Filament\Tables\Columns\Layout\Stack;
use Filament\Tables\Columns\TextColumn;
Split::make([
ImageColumn::make('customer.avatar')
->circular()
->grow(false),
TextColumn::make('reference')
->weight(FontWeight::Bold)
->searchable()
->sortable(),
Stack::make([
TextColumn::make('customer.email')
->icon('heroicon-m-envelope'),
TextColumn::make('customer.phone')
->icon('heroicon-m-phone'),
])->visibleFrom('md'),
])->from('lg')
Split lays its children out side by side from the breakpoint you pass to from(), and stacks them below it. That is more work and more opinionated than one method call, but it is the only way to control where the stack happens. I reach for stackedOnMobile() first and only move to Split when a real device shows me the default is wrong.
Trim the columns that do not belong on a phone#
This is the step people skip, and it is why their first attempt looks worse than the horizontal scroll. Stacking an eleven-column table produces an eleven-line card per row, so five rows fill three screens of scroll. You have moved the problem from the x axis to the y axis.
Use visibleFrom() to keep audit metadata off small screens entirely:
TextColumn::make('reference')
->searchable()
->sortable(), // identifies the row — always visible
TextColumn::make('customer.name')
->searchable(), // identifies the row — always visible
TextColumn::make('status')
->badge(), // drives the decision — always visible
TextColumn::make('total')
->money('GBP')
->sortable(), // drives the decision — always visible
TextColumn::make('created_at')
->dateTime()
->visibleFrom('md'), // context, not a decision
TextColumn::make('updated_at')
->dateTime()
->visibleFrom('md'), // audit trail
TextColumn::make('source')
->visibleFrom('lg'), // analytics, never read on a phone
TextColumn::make('id')
->toggleable(isToggledHiddenByDefault: true),
hiddenFrom() is the inverse if a column only makes sense on small screens. The editorial rule I use: a cell survives on mobile if it either identifies the row or drives the decision the user came to make. Everything else is context, and context belongs on the record page. Three or four cells is a readable card. Seven is a form.
If your table carries summary rows, check them at mobile width after trimming — column totals from summarizers stack alongside the data cells, and that was exactly the case v5.2.2 had to fix.
Give every stacked cell a label that carries its meaning#
In a normal table the header row carries the meaning and the cell carries only the value. Stack the row and the header is gone, so a bare £148.20 sitting under a bare Shipped tells the reader nothing. Filament renders the column label above each value in a stacked row for exactly this reason, which means your labels are now user-facing copy rather than developer shorthand.
Audit them. created_at auto-labels to "Created at", which is fine; customer.name auto-labels to "Customer.name", which is not:
TextColumn::make('customer.name')
->label('Customer'),
TextColumn::make('total')
->label('Order total')
->money('GBP'),
A few cells read worse with a label than without — a leading avatar, or a status badge that already reads as its own word. The Column class carries a hidden-label concept (custom column views can check $column->isLabelHidden()), but it is not documented on the v5 columns page, so confirm the method exists in your installed version before you lean on it. The portable alternative is to fold the value into a Split or Stack layout, where there is no label row to begin with.
Collapse row actions into a single menu#
Row actions are usually the first thing to break on a phone. Three side-by-side buttons that fit comfortably in a 180px desktop cell become a cramped line across a 343px card, and the delete button ends up a thumb's width from edit.
Group them:
use Filament\Actions\ActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
->recordActions([
ActionGroup::make([
ViewAction::make(),
EditAction::make(),
DeleteAction::make(),
]),
])
One trigger button, a dropdown, and destructive options safely separated from the common ones by a deliberate tap. Anything destructive should also be confirming — the pattern for confirmation modals and notifications on a custom action carries over unchanged, and a confirmation modal is worth more on mobile than on desktop because mis-taps are more likely than mis-clicks.
Keep sorting and filtering reachable#
Filament gives you a sort dropdown on mobile automatically, so the sorting you lost with the header row comes back. Filters are your problem. A filter panel that opens as a wide dropdown on desktop is unusable at 375px, so push filters into the modal or slide-over layout, and promote the one or two filters people actually use into something they can hit without opening anything.
Status tabs are the best version of this on a phone — a horizontal row of single-tap targets with counts, which survives a narrow viewport far better than a filter form does. If the table is status-driven, status tabs with counts on a list page will do more for mobile usability than the stacking did.
Size the touch targets for a thumb#
Stacked rows fix reading, not tapping. Once the row is a card, every link, badge-with-an-action and group trigger inside it is a touch target, and desktop-sized icon buttons are too small for a thumb.
WCAG 2.2 sets 24×24 CSS pixels as the AA minimum (SC 2.5.8) and 44×44 as the AAA target (SC 2.5.5). Aim for 44 and stop arguing about it — the extra padding costs you nothing on a card that is already a vertical stack.
The right lever is pointer type rather than screen width, because a tablet in a stand is a coarse pointer at desktop width and a 375px browser window on a laptop is a fine pointer at mobile width. Tailwind v4's pointer-* variants for sizing touch targets by input device size against the actual input rather than guessing from the viewport, which is the distinction that matters here.
Test at a real 375px viewport#
Narrowing a desktop browser window is not the same test. The breakpoint fires identically, so the layout looks right and you ship. The usability problem lives in the parts the desktop window cannot show you: tap accuracy with a thumb, how far the card scrolls under a soft keyboard, and whether a dropdown opens above or below the fold.
Use device emulation with touch simulation on, at 375×667 — still a realistic floor — and then open it on an actual handset before you call it done. Walk one real task end to end: find a row, read its status, open its action menu, complete the action. If any step needs a horizontal scroll or a second attempt at a tap, you are not finished.
Gotchas and edge cases#
A custom column view written against a <td> context can render wrong once stacked. Filament's generated custom columns are plain Blade views with a <div> wrapper, but hand-written ones that assume table-cell display, rely on cell borders, or set white-space: nowrap will fight the card layout. Grep your resources/views/filament/tables/columns directory before enabling this.
Fixed widths set for desktop are the other common fight. ->width('1%') on an icon column and ->wrap() tuned for a 240px desktop cell both behave differently inside a stacked row. Audit anything where you forced a dimension.
Stacked rows do not shorten the query. The table that took 1.8 seconds to paginate on desktop takes 1.8 seconds on mobile, and now the user is on a worse connection while it happens — query tuning for large Filament tables is a separate job and usually the more valuable one.
Finally, do not stack everything reflexively. A table someone uses to compare rows against each other — a reconciliation screen, a price list — loses its whole point as a stack of cards, and a horizontal scroll with two or three columns is the better answer there.
Wrapping up#
Add stackedOnMobile(), then spend the real time on the column trim and the action group — those two decide whether the result is readable or just vertically long. Test it at 375px on a device with a touch screen, not a narrowed window.
If the table is status-driven, add status tabs with counts next, since they are the single best mobile affordance in a Filament panel. And if the same table is also slow, fix the query before the layout — stacking a slow table just gives people something to read while they wait.
FAQ#
How do I make a Filament table responsive on mobile?
Call stackedOnMobile() on the table in your resource's table() method. Below the mobile breakpoint each row renders as a labelled vertical card instead of a horizontal row, and above it the normal table returns. Pair it with visibleFrom() on the columns that do not need to appear on a small screen, or the card becomes very tall.
What are stacked table rows in Filament v5.2?
Stacked table rows are a layout introduced in Filament v5.2.0 that converts each table row into a card where the column label sits above its value. It is a CSS-level change to the same markup rather than a separate mobile component, so sorting, searching, filters, row actions and bulk selection all continue to work.
How do I hide a Filament table column on small screens?
Use visibleFrom() on the column, passing a Tailwind breakpoint such as md or lg, and the column only appears from that width upwards. hiddenFrom() does the inverse. Both work whether or not the table is stacked, and they are the main tool for keeping a stacked card down to three or four meaningful cells.
How do I stop a Filament table scrolling horizontally on a phone?
Horizontal scroll is Filament's default overflow behaviour, so you have to replace it rather than disable it. stackedOnMobile() removes the need for it by stacking the cells vertically, and trimming columns with visibleFrom() removes the width that caused it. For a breakpoint other than the built-in mobile one, use a Split layout with from() instead.
Can I show a different layout for a Filament table on mobile?
Yes, and that is what the layout components are for. Split, Stack, Grid and Panel let you define exactly where content sits at each breakpoint, including Split::make([...])->from('lg') to choose where the stacking starts. You are not rendering a second view — the same columns are arranged differently per breakpoint.
How do I collapse Filament row actions into a menu on mobile?
Wrap the actions in an ActionGroup inside recordActions(). They render as a single trigger button that opens a dropdown, which is one predictable touch target instead of three cramped ones. It applies at every width, not just on mobile, which is usually what you want anyway.