An index page built from a Page and a DataTable that owns its own search, filters, and pagination.
A list page is the front door to a collection: every segment, every broadcast, every export, with the controls to narrow the collection down and a way into a single item. It is the most repeated surface in the product, so it is the one where small disagreements between screens are most visible.
Build it from a Page whose body holds a single DataTable. The DataTable already owns the search input, the filters button, sorting, column settings, pagination, the loading skeleton, and the empty state. A list page is mostly a matter of turning those on and writing the copy for them, not of assembling them.
Three decisions, in this order:
isFullScreen makes the header full-bleed
with a divider under it; bodyContentWidth="standard" holds the body between 950px and
1850px, so the table neither collapses on a laptop nor stretches the full width of a very wide
monitor.Set the width once on the Page rather than on the body. Every PageBody and PageSection
inherits it, so a header and a body assembled in different route files still agree.
Use three slots and no more:
PageHeaderTitle is the collection as a plural noun, matching the navigation label exactly.
"Segments", not "All segments" or "Segment list".PageHeaderDescription is one sentence saying what the collection is for. Drop it when the title
already says everything, which is most of the time. A description that only restates the title
costs a line of vertical space on every visit.PageHeaderActions holds the action that adds to the collection, as a single primary button.
Secondary page-wide actions such as "Import" go here too, but keep the total to two.Leave PageHeaderIconTile and PageHeaderLabel off. Both describe one subject with a status, which
is a detail page, not a collection.
Use PageHeaderTabs only when the tabs partition the same rows with the same columns, such as
"Active" and "Archived". If a tab changes which columns make sense, it is a different page.
Search and filters never go in the header. They belong to the table, they move with it, and the table already has a place for them.
Turn both on through the table: withSearch and withFilters with a filters config. The DataTable
renders the search input and the filters button together on its actions row, and drops the active
filter chips into a row directly below. Sorting and column settings sit on the trailing edge of the
same row.
That single row is the whole argument for keeping them there. Search, filters, sort, and columns are four ways of asking the same question, they line up next to each other for free, and their spacing matches on every list page in the product without anyone choosing a value.
Reach for the standalone Search and Filters components only when there is no table under them, for example a filtered grid of cards.
For search behavior, follow Search: server-side search uses
shouldSearchOnEnter with shouldSearchOnClear, client-side search uses shouldSearchOnInput with
shouldSearchOnClear. Filtering is always controlled. The DataTable renders the chips and reports
the changes; the page owns the query and passes back filtered data.
Keep that query state on the route rather than inside the table's component, next to the page number
and beside whatever puts it in the URL. Search, filters, and page all narrow the same request, they
all belong in a shareable link, and a filter change has to reset the page to 1, which is awkward
if the two live in different places.
Turn search and filters off below a handful of rows. A filter button over four items is a control that cannot pay for itself, and the row it sits in is better spent on the data. Pick a threshold and gate the props on it.
Once a list has enough filters that people rebuild the same combination daily, add
withSavedViews. It is a better answer than more default columns or a second page.
Saved views and PageHeaderTabs can both slice a collection, so keep the split by who owns the
slice. Tabs are the ones you defined, they are part of the page, and they belong in a URL. Saved
views are the ones the person defined, and they belong to that person.
Use withPagination, and let it render one set of controls below the table and outside its panel.
That is where people already look, and it leaves the panel's bottom edge as the end of the data.
One set, not two. Repeating the controls above the table is a habit worth dropping: it costs a row of vertical space before anyone has seen a result, and it puts two live copies of the same page state on screen. If the argument for the top copy is that the list is too tall to scroll back up, the answer is a smaller page size.
Pass pagination.page, pagination.onPageChange, and pagination.totalItemCount once the server
does the paging. When the page number lives in the URL, pass hrefBuilder instead of
onPageChange so the controls render as links and a middle-click opens a new tab. See
Pagination for continuation-based paging and for the unknown-total case.
Prefer pagination to withVirtualizer on a list page. Infinite scrolling suits a viewport-height
panel inside a larger screen; on a full page it takes away the footer, the position in the
collection, and any stable link to a row.
Nothing on a list page needs a gap prop. Both containers already own their spacing:
| Boundary | Owner |
|---|---|
| Header to body | PageHeader, via the full-bleed divider |
| Section to section | PageBody, via pageSectionGap |
| Table header to table to pagination | DataTable |
| Cell padding and row height | DataTable |
A gap or a margin you add on top of these is a value one screen holds and the next one does not.
The table stacks its panel and its pagination at gap="150"; putting a different value beside it
just makes the two rhythms visibly disagree.
Where you genuinely do own the spacing, there are two values worth knowing. Use 200 for a toolbar
you had to build yourself, which is what the hand-built ones in the product already settle on. Use
100 between adjacent buttons. Everything else follows Density.
Use DataTable for a list page. Search, filters, sorting, pagination, column management, skeleton rows, and the empty state are all features it has and Table does not, and a list page eventually wants most of them.
Use Table only for a read-only list that will never need those, typically a short panel on a
detail page. If you find yourself adding a standalone Search, Filters, or Pagination next to a
Table, you are rebuilding the DataTable header by hand. Switch.
Leave layout at its default fixed so columns divide evenly and the table cannot scroll
sideways. Set layout="auto" when a column holds values that must be read in full, and accept
horizontal scrolling as the cost. Give the identifying column the most room with
meta: { width: '2fr', minWidth: 200 }, keep the rest at 1fr, and hide the columns most people
do not need behind withColumnsSettings with defaultColumnVisibility.
Keep the visible column count low. Anything that reads as prose belongs in the row's detail page, not in a cell.
A list page has to resolve all five states before it counts as paved road. States is the reference for each one; what follows is only where each lands on this layout.
Loading. A list page loads in two stages, and they get different treatments.
The rows are the common case: pass isLoading on the table with loadingState.rowCount set to your
page size, so the skeleton occupies the height the rows will. The header, search, and filters stay
mounted and usable throughout, which matters because a re-search or a filter change puts the table
back into this state on a page the person is already reading.
The route itself resolving is the rarer case, and it belongs on PageBody's isLoading rather than
Page's. Under a full-bleed header the title and tabs are already known, so keeping them gives
someone an anchor while the body fills in. Save Page's isLoading for a page whose header depends
on the data it is still fetching.
Empty. The table renders the empty state on its own when data is empty; you supply the copy.
Two different situations reach it, and they need different copy, so derive the config from whether a
query is active rather than hardcoding one:
The first-use action repeats the header's button on purpose. It is the only thing to do on the screen, and the empty state is where the eye is.
Partial. Some rows loaded and some did not, or a bulk action half succeeded. Put a caution
Banner in a PageSection above the table and leave the rows that did load in
place. Lead with what worked, then name the gap.
Error. A failed load is a Banner in a PageSection above the table, with a retry action; keep
the header so the person still knows where they are. A failed row action or bulk action is a
Snackbar, not a banner, because the page it happened on is still correct.
Success. Row actions and bulk actions confirm with a Snackbar and refresh the rows underneath.
Do not navigate away from the list to confirm something that happened on the list.
Two sections rather than one, so the banner keeps pageSectionGap from the table and the table's
panel edge stays unbroken. Leave the sections undivided; the gap already separates them, and a rule
so close to the header's own border reads as clutter.