TableToolbar
A Sort/Filter/View settings chip toolbar, composable above any table.
The Sort, Filter, View settings and Import/Export controls from a record grid, as a standalone primitive: the sort popover with drag-reorderable condition rows, the field picker, the filter chips with their operator menus, a View settings popover that shows, hides and reorders attributes, and an Import/Export menu (Import CSV / Export view as CSV / Export view as Excel / Learn about imports and exports). RecordsTable renders this same measured chrome internally — the CSS comes from one shared source, so the two can't drift apart.
DataTable's generic API with the rich toolbar above it — sort by any field, add a filter, and the grid below follows. This is the composable version, for putting above DataTable or any table of your own.
Separate Sort and Filter fields
Sort and Filter read the same columns list by default. When a consumer's two menus genuinely offer different fields — measured on Attio's own Tasks page, Sort has Due date/Assignee/Creation date, Filter has Assignee/Record/Created by, and neither is the other's full set — give each its own list instead.
Checkbox, multi-select, status and actor columns
TableToolbarColumn.type also takes "checkbox", "multiselect", "status" and "actor" (carrot-systems/crisp#1158), each with the operator set Attio measures for that attribute type. Pass columns and currentUser in the fourth argument of applyFilters so the helper can evaluate them.
type | Operators | Value editor |
|---|---|---|
"checkbox" | is true, is false | none; the chip reads [Done | is true] |
"multiselect" | contains, does not contain, empty, not empty | one option from a searchable list (values); free text without values |
"status" | is, is not, was, was not, empty, not empty | one option from a searchable list (values), with an 8px colour dot per valueColors entry |
"actor" | is, is not, empty, not empty | searchable list: Current user first, then actors, each with a 16px circle avatar (src, or the label's initial) |
None of the four offers is any of, as in Attio.
Multi-value filters: valuesNoun and is none of
A column with values can opt in to Linear's multi-value filter by setting valuesNoun, the plural noun the chip uses once two or more values are picked (crisp#1332):
const COLUMNS: TableToolbarColumn[] = [
{
key: "stage",
label: "Stage",
icon: CircleDot,
type: "status",
values: ["Lead", "Won", "Lost"],
valuesNoun: "statuses", // the chip reads "2 statuses"
},
];With valuesNoun set on a column that has values:
- The chip reads
2 statusesfor two or more values (one value still shows its name). - The operator list gains
is none of("isnoneof") next tois any of.applyFiltershonours it: a row matches when its value is in none ofvalues. A condition with novaluesis inert. - For a
selectorstatuscolumn, theis/is notvalue picker becomes a checkbox list, and the operator follows the number of checked values:isbecomesis any of,is notbecomesis none of, and back again when one value is left. Other column types keep the operator you chose.
A column without valuesNoun renders and filters exactly as before (the chip lists the names and is stays a single pick). To change the word, pass operatorLabels={{ isnoneof: "is none of" }}. ToolbarOperatorLabels now has a required isnoneof key, so a consumer that types a complete label map must add it.
Advanced filters
With two or more chips, each chip's kebab (⋮) offers Convert to advanced filter next to Delete filter (with one chip it is only Delete). Converting collapses every condition into one Advanced filter N chip. Its popover lists the conditions as Where | field | condition | value | ⋮ rows; every later row starts with a clickable And / Or that flips on click. + Add filter stays disabled until the last row is complete (field, condition and, when the condition needs one, a value); Clear all filters removes everything.
Long value lists and long values
A value picker with many options (a supplier list of 25 or more) is capped to the space left below its chip, flipping above it when there is more room there, and scrolls inside itself with its search box pinned on top; arrow keys move through the options. A long chosen value ellipsizes inside its chip (the full text is in the chip's title) instead of wrapping or being cut mid-glyph.
The model is additive. ToolbarFilter.join ("and" | "or") is the join to the previous condition (the first's is ignored; unset is "and"), and ToolbarFilter.group marks conditions that render as one advanced chip (the toolbar writes TOOLBAR_ADVANCED_GROUP). A plain array with neither behaves exactly as before: every condition must match. applyFilters reads join left to right with and binding tighter than or, so a or b and c is a or (b and c) and a and b or c is (a and b) or c. Labels: convertToAdvancedLabel, advancedFilterLabel(n), deleteConditionLabel, andLabel, orLabel, whereLabel, addFilterLabel, clearAllFiltersLabel.
By default the View settings button ends in a chevron and the Sort, Filter and View settings icons are 14px. Attio's View settings trigger has no chevron and its icons are 16px, so pass viewSettingsChevron={false} and toolbarIconSize={16} to match it (crisp#1291). toolbarIconSize sizes only those three icons, not the chevron, Import/Export or any menu content. Both are additive, and the defaults render exactly as before.
The View settings and Import/Export menus stay inside the window (crisp#1294), and a menu placed by right no longer stretches to the window's left edge (crisp#1299).
Inside a DataTable toolbar slot the toolbar row wraps onto its own line instead of overlapping the search and count, and the search keeps a 160px floor (crisp#1321). Below a 320px toolbar width the Sort, Filter, View settings and Import / Export triggers drop their labels and keep their icons as 28px squares; each keeps its accessible name, and a trigger portaled out through viewSettingsRef or importExportRef keeps its label.
The toolbar row wraps when its chips do not fit, so the last chip and its ⋮ never leave the viewport, and the Sort popover is clamped to the viewport width.
Installation
npm install @crisp-ui-kit/crispProps
Prop
Type
ToolbarTool
The Sort/Filter chip's own chrome (.crisp-tabletoolbar-tool), standalone (#399) — for a
control drawn like Sort/Filter that isn't Sort or Filter (a screen's own quick filter, e.g.
"My tasks 5"). Only paints inside a TableToolbar, the same way every other rule in its
shared chrome does; count renders a small neutral trailing badge, measured live on two
independent Attio surfaces (the Sort tool's own "+N" overflow, a Kanban board's stage-column
count).
Prop
Type
The toolbar owns the sort and filter state; two pure helpers turn that state into rows. They read the raw value, never a formatted display string, so a column shown as $1,200 still filters on 1200 and sorts numerically. Omit the controlled props to let the toolbar manage its own state (seed it with defaultSorts / defaultFilters) and just listen to the callbacks.
Helpers
| Function | Description |
|---|---|
applyFilters(rows, filters, get?, options?) | Filters any row array. contains / does not contain / starts with / ends with / is / is not / is any of / empty / not empty, plus is true / is false (a missing cell is false) and was / was not; a condition with no operator chosen yet, or no value (or no checked values, for is any of), is inert. |
options (all optional, ApplyFiltersOptions) carries what the four typed columns need beyond the row value: columns (the toolbar's own list: a "multiselect" column's contains / does not contain then test array membership, so Red does not match Dark red, and an actor is Current user is recognised by the column's currentUserLabel); currentUser (what Current user resolves to in your rows' terms, an id or a name; unset, it is compared as the literal label); and history(row, key) (the values a row held before now, for was / was not; unset, only the current value counts, so was equals is). Without options every original semantic is unchanged.
The Current user row stores the literal string "Current user" (TOOLBAR_CURRENT_USER, or the column's currentUserLabel) as the condition's value, so you can also resolve it yourself.
Dates
Both helpers read the raw value, so a column displayed as $1,200 still matches 1200. A
Date sorts by instant — two times on the same day order correctly — and is matched as its ISO
date, so contains "2026-06" works. It will not match 16/06/2026: filtering a date by text is
a stopgap, and the date-aware operators (before, after, between) are a date-picker UI, which
has to be measured before it can be built.
| applySorts(rows, sorts, get?) | Sorts any row array, in order — sort 0 wins, later sorts break ties. Numbers compare numerically, Dates by instant, everything else by locale. |
Pass get when a field isn't a plain row[key] — for example to read row.company.name.
Notes
- Operators.
contains,does not contain,starts with,ends with,is,is not,empty,not empty— Attio's own text-filter operator picker, in its measured order (#619).is any of— a searchable checkbox list of the column's declared values, Attio's faceted multi-select filter — is added for any column with avaluesarray (TableToolbarColumn.values); omit it for a free-text column, since there's nothing to check off. TableToolbarColumn.type("text"|"number"|"date", default"text") swaps in Attio's own column-typed operator set (#593):"number"offersis/is not/less than/greater than/empty/not emptywith a real number-spinner value input;"date"offersis/is not/before/after/empty/not emptywith a calendar (crisp's ownDatePicker) plus Today/Tomorrow/Next week quick-picks — Attio's own picker has exactly these 3 presets, not a longer relative-date list. A date condition's value is a plain ISO string ("2026-09-25");applyFilterscompares it chronologically forbefore/afterand numerically forless than/greater than, not as text.- The filter chip is 4 independently-clickable segments — field, operator, value, kebab — not one combined control, matching Attio's own chip (measured live): the field segment reopens the attribute picker and replaces the field in place; the operator segment opens an operator-only menu, then auto-opens the value popover (focused) for an operator that needs one; the value segment opens its own popover, independent of the operator menu. No operator is pre-selected — a freshly-added condition reads "Select condition" until one is actually picked. The field/value segments read a muted fill until the condition has a value; the operator/kebab segments stay white throughout.
- Sort order is drag-reorderable. Grab a condition row's grip in the sort popover; the first sort wins and later ones break ties, which is what
applySortsimplements. - Menus close on outside click or Escape, like every other overlay in the kit.
sortColumns/filterColumnsdefault tocolumns, independently. Passing one alone leaves the other oncolumns; passing both makescolumnsitself optional, since nothing reads it directly anymore.- A filter chip's "⋮" opens a kebab menu with one action, "Delete filter" (#594) — Attio's own chip has no direct "x". Danger-styled with a leading trash icon. Unlike the sort/condition popovers (
role="dialog"), this one is a realrole="menu"running crisp's shared keyboard model (useMenuKeyboard: arrows, Home/End, typeahead, Escape). empty/not empty, notis empty/is not empty. Attio's own text-operator picker (and its number/date sets) uses the shorter wording — this is whatOP_LABELSrenders, in the operator menu and in a chip's own summary text.- The Import/Export trigger only renders once wired (#595): pass at least one of
onImportCsv/onExportCsv/onExportExcel/importExportHelpHref, or nothing shows — an unwired menu has nothing to do. Its 4 items (Import CSV / Export view as CSV / Export view as Excel / Learn about imports and exports, separators between each pair, measured live in that exact order) render only for the callbacks actually given. It's a realrole="menu"(useMenuKeyboard, same as the filter chip's kebab). It shares its "raised" chrome (bgSurface/white fill,elevation.field's shadow, a trailing chevron) with View settings and the view switcher (#772, #784) — all three read identically on Attio; only Sort and Filter use the plain ringed.toolinstead. - View settings (#590) is a draggable list of shown attributes, not a checkbox menu — measured live against Attio's own popover: grip + type icon + label per row, reordered by drag (the same native-HTML5-drag mechanism the sort rows use), each with a trailing hide control; a footer "Add attribute to view" swaps in a search screen listing the rest, click-to-add.
viewColumnsdefaults tocolumns, the same fallbacksortColumns/filterColumnsuse;shownKeysdefaults to every key inviewColumns, in order — "everything shown" unless told otherwise. Attio's own popover also carries a per-row "View attribute menu" kebab whose contents weren't explored (undefined scope) — not built here. type: "select"(#715):is/is not/empty/not empty, plusis any ofwhen the column also hasvalues— a closed-vocabulary column's condition offers no text-search operators (contains,starts with, …), since they mean nothing against a fixed value set. Whenvaluesis given,is/is not's own value editor is a searchable single-select dropdown of those values (#747, measured live against Attio's own status-field filter), not a text box — the same closed-vocabulary constructis any of's checkbox list already uses, single-pick instead of multi.- A column with
nestedColumnsreaches a related object's own attributes (#592, #754): the Filter field picker, Sort's field picker, and View settings' add-attribute screen all drill in (back control, a scopedSearch ${noun} attributes…placeholder) rather than only offering the relationship field itself — which stays independently selectable from inside its own nested screen too, listed first. Typing a query at any level surfaces a matching nested field too, breadcrumbed ("Team › Twitter follower count"), alongside a direct match — measured live against Attio's own Companies picker. Picking a nested field applies a dot-joined key ("team.twitterFollowerCount"); the chip/trigger/shown-attribute row all render the full path, and the toolbar's own "Sorted by X" label resolves it too. viewSettingsRef/importExportRefrelocate View settings and Import/Export out of this toolbar's own row, INDEPENDENTLY (#766, #772): Attio's own layout splits the toolbar into two rows — a view bar (the view switcher and View settings together on the left; Import/Export and the primary "+ New" action together on the right — the two are NOT adjacent, a flexible gap sits between them) and, below it, a row holding only Sort and Filter, 21px down, left-aligned to the same x as the view switcher, with a hairline separator between the two rows — all measured live. PairTableToolbarwithSavedViews(the view switcher) and pointviewSettingsRefat a<span>/<div>rendered next toSavedViews' own trigger, andimportExportRefat a separate one rendered next to your own primary action; each trigger portals to its own target (its divider goes with it), and this toolbar's own row keeps only Sort and Filter. Omit either ref and that control renders exactly where it always has, inline, in this row — the default is unchanged, existing consumers see no difference. The relocated trigger carries its owntabletoolbarRecipe()wrapper internally, so it keeps its full measured chrome even outsideTableToolbar's own root — nothing for a consumer to do there. Its POPOVER portals separately, todocument.body(#807) — it doesn't follow the trigger to the consumer's own target, since aposition: fixedbox anchored to the trigger's own rect is what lets it escape any stacking context that target happens to sit inside (a real consumer header can be an earlier DOM sibling of the rest of the page, in its own stacking context — z-index alone can't reach across that). See the example above for the full two-row composition, includingDataTable's ownshowCountsetfalse— Attio's toolbar never carries a row count; that lives in the grid's own calc footer.- Sort and Filter show a DASHED border while empty, switching to the existing solid ring once a value is set (#784, measured live: Attio's own empty Filter chip has a
1px dashed rgba(0,0,0,.06)::before, not a flat fill) — matches automatically, nothing for a consumer to wire; the dashed treatment is driven entirely bysorts.length/activeFilters, the same state that already drives--active. - The "Sorted by X (+N)" label is 3 separately-styled parts, not one flat string (#788, measured live on the chip's own leaf text nodes, not the button root): "Sorted by" muted, the field name at full contrast, "+N" smaller and its own grey — all the same font-weight, the difference is colour/opacity (and size, for the suffix).
sortTriggerLabel's own custom-formatter escape hatch still returns a plain string (a caller-supplied formatter can't be auto-split), and the empty-state label is unaffected — only the default 2/3-part render changed.