Appearance
FilterRail
FilterRail shows every filterable axis beside the results instead of hiding it behind a filter dropdown. Each section shows its options up front, so users see what they can filter by without opening anything.
It writes the same CollectionControl a DataTable reads, so there is no second filter engine: a checkbox section is the in operator, a radio section is eq, a range is between.
tsx
import { FilterRail, type FilterRailSection } from "@tailor-platform/app-shell";
const sections: FilterRailSection[] = [
{
id: "category",
label: "Category",
control: "checkbox",
field: "category",
options: categories,
},
{ id: "status", label: "Status", control: "radio", field: "status", options: statuses },
];
<FilterRail.Root control={control} sections={sections}>
<FilterRail.Sections />
</FilterRail.Root>;Sections and parts
What the rail shows is config: sections, one entry per axis, in screen order. How the rail is assembled is parts: leave a part out and that feature is not rendered.
| Part | Renders |
|---|---|
FilterRail.Root | The rail. Takes control, sections, layout. |
FilterRail.Header | A title row with a slot for actions. |
FilterRail.ClearAll | Clears the rail's own filters only. Hidden while none are active. |
FilterRail.Settings | Show / hide, reorder and sort sections. Needs layout. |
FilterRail.Sections | The sections, in the user's layout. Scrolls while the header stays put. |
FilterRail.Provider | Holds the config when a Trigger sits outside the rail (see Narrow screens). |
FilterRail.Trigger | The narrow-screen "Filters" button, placed anywhere inside Provider. |
tsx
<FilterRail.Root control={control} sections={sections} layout={layout}>
<FilterRail.Header>
<FilterRail.ClearAll />
<FilterRail.Settings />
</FilterRail.Header>
<FilterRail.Sections />
</FilterRail.Root>Section types
Every section has an id, a label and an optional hint. The operator is fixed per section — the user picks values, never operators.
control | Commits | Notes |
|---|---|---|
checkbox | in (or nin) with the ticked values | sort, truncate (default 8), pinned, searchThreshold |
radio | eq (or ne) with one value | An "Any" row removes the filter; anyLabel: null hides it |
boolean | eq with true / false | Three states: Any, trueLabel, falseLabel |
text | contains, hasPrefix or eq | Debounced (debounceMs, default 250) |
numberRange | between { min, max }, or gte / lte | Commits on blur / Enter; one end alone is half-open |
date | eq, gte or lte a YYYY-MM-DD | Uses DatePicker |
dateRange | between { min, max } | Uses DateRangePicker; optional presets |
custom | Whatever render writes | Declares the fields it owns so Clear all includes them |
A checkbox list long enough to be truncated also gets a search box, so a value can be found without scrolling (searchThreshold overrides when it appears).
A field holds one filter, so two sections must not share a field; the rail warns in the console when they do.
Typing fields
Give FilterRailSection the row's keys so a field that is not on the table is a type error, the same way createColumnHelper<Row>() types columns. This works with a typed control or a plain one:
tsx
const sections: FilterRailSection<keyof Product & string>[] = [
{ id: "category", label: "Category", control: "checkbox", field: "category", options },
// field: "categroy" → type error
];With a typed control (from useCollectionVariables({ tableMetadata })), a section whose field the control does not have is also rejected.
tsx
const stockLevel: FilterRailSection = {
id: "stockLevel",
label: "Stock level",
control: "custom",
fields: ["stock"],
render: ({ getFilter, setFilter }) => (
<Button
variant="outline"
size="xs"
onClick={() => setFilter("stock", { operator: "between", value: { min: 1, max: 20 } })}
>
Low stock
</Button>
),
};The rail never hides state: a selected option is never truncated away, never disabled and never hidden by a section's search box.
Collapsing sections
Each section collapses and expands from the chevron by its title. Sections start expanded; set defaultCollapsed: true to start one collapsed (for a rarely used axis). While a section is collapsed, its header still shows how many values are selected and keeps its Clear, so a collapsed section never hides an applied filter. The open / closed state is kept while the page is open, including across the narrow-screen sheet opening and closing.
tsx
{ id: "warehouse", label: "Warehouse", control: "checkbox", field: "warehouse", options, defaultCollapsed: true }Choosing a control
Checkbox and radio sections list every option, so they are only for small, fixed sets whose values you know up front — status, category, size, a handful of warehouses. Their options are a static array; the rail does not fetch them.
Do not use a checkbox or radio list for a field whose values keep growing — product names, SKUs, customers, suppliers, order numbers. The list gets longer with every new record, takes over the rail, and becomes a scroll hunt. Give those fields a search instead:
| The field's values are… | Use |
|---|---|
| A small fixed set (≈ up to 20), pick any | checkbox |
| A small fixed set, pick one | radio (or boolean for yes / no) |
| Open-ended and growing (names, codes, customers) | text — a search that commits contains, hasPrefix or eq |
| Open-ended, but users must pick exact records | a custom section rendering a searchable picker (e.g. an async Combobox) |
| Numbers or dates | numberRange, date, dateRange |
In development the rail warns in the console when a checkbox or radio section lists more than 50 options, as a nudge towards a search.
User layout
useFilterRailLayout(storageKey) persists the user's section order, hidden sections and option sort to localStorage. Pass it as layout and add FilterRail.Settings. Hiding a section that carries a filter also clears that filter. To share a layout across a team, pass your own { value, onChange, onReset } binding instead.
Beside a DataTable
The rail sits in its own Layout.Column, outside DataTable.Root, which is why control is a prop. Do not also render DataTable.Filters for the same fields — one fact in two places can drift.
Narrow screens
Below the lg breakpoint there is no room for a side column, so the rail turns itself into a left sheet behind a Filters button carrying the active count — the same way the Sidebar handles small screens. Apps write no breakpoint code.
By default the button appears where the rail would have been. To put it somewhere else — a table toolbar, a page header — wrap the page in FilterRail.Provider, move the config there, and place FilterRail.Trigger wherever you want it. The Trigger renders nothing on wide screens, and while one is mounted the rail skips its own button.
tsx
<FilterRail.Provider control={control} sections={sections}>
<Layout fill>
<Layout.Column area="left">
<FilterRail.Root>
<FilterRail.Sections />
</FilterRail.Root>
</Layout.Column>
<Layout.Column area="main">
<DataTable.Root value={table}>
<DataTable.Toolbar>
<FilterRail.Trigger />
</DataTable.Toolbar>
<DataTable.Table />
</DataTable.Root>
</Layout.Column>
</Layout>
</FilterRail.Provider>