Filter chips
The filter toolbar: dashed pills that open staged editors, one open at a time, plus a searchable menu for reaching every filter a table has.
Preview
Interactive examples use the same @flux/ui build as production. Toggle theme in the header for light and dark. Motion tokens and layout patterns live under Motion and Patterns in the sidebar.
Live preview
Open one chip, then click another — the second opens cleanly, with no flash.
Try typing usd or hdfc in the Filter menu: searching matches filter names and their values at once, and picking a value promotes that filter to a chip with the value already applied. With the query empty the rows are toggles — ticked filters are in the toolbar, and the menu stays open so turning several on is one visit.
Installation
Add the package to your app (full Next.js + Tailwind steps on Installation):
npm install @payglocal_ui/flux-uiUsage
Import
import {
FilterToolbar,
FilterChipGroup,
SelectFilterChip,
SingleSelectFilterChip,
DateRangeFilterChip,
CalendarDateFilterChip,
NumberRangeFilterChip,
TextFilterChip,
MonthRangeFilterChip,
AddFilterMenu,
FilterChip,
} from "@payglocal_ui/flux-ui";Example
// FilterToolbar lays out search / chips / actions and wraps the chips in
// a FilterChipGroup, so exactly one popover is open at a time — and
// switching between two chips does not make the second one flash.
//
// That flash is why the group exists. Every chip sharing one openChip
// value while Radix reports the switch as two separate events means a
// naive setOpenChip(open ? key : null) lets whichever event lands second
// win: when the dismissal lands second it wipes out the chip that just
// opened, so it mounts, paints and unmounts. The group makes a close
// count only if the chip closing is still the one on screen. No page
// needs openChip state any more.
<FilterToolbar
search={<SearchInput />}
chips={
<>
<DateRangeFilterChip value={date} onChange={setDate} />
<SelectFilterChip
label="Status"
options={STATUS}
selected={status}
onChange={setStatus}
/>
<NumberRangeFilterChip value={amount} onChange={setAmount} prefix="₹" />
<SingleSelectFilterChip
label="Settlement cycle"
options={CYCLES}
value={cycle}
onChange={setCycle}
showValueInLabel
/>
</>
}
actions={<Button>Export</Button>}
/>
// ── Reaching every filter ─────────────────────────────────────────────
// AddFilterMenu replaces a "More filters" drawer of leftovers. Typing
// searches filter NAMES and their VALUES at once, so someone who knows
// they want "USD" finds it without first knowing it lives under
// Currency — and choosing anything promotes that filter to a real chip,
// so there is exactly one place a filter can be.
<AddFilterMenu
filters={[
{ key: "currency", label: "Currency", options: CURRENCY, activeCount: currency.length },
{ key: "acquirer", label: "Acquirer", options: ACQUIRER, activeCount: acquirer.length },
{ key: "amount", label: "Amount" }, // no options: findable by name
]}
visibleKeys={visible}
onAddFilter={(key) => setVisible((v) => [...v, key])}
// Supply this and the filter rows become toggles: a ticked filter is in
// the toolbar, and the menu stays open so turning several on is one
// visit. Removing must also CLEAR that filter — a chip is shown whenever
// it holds values, so a filter removed with values still applied would
// either bounce straight back or keep narrowing the table invisibly.
onRemoveFilter={(key) => { setVisible((v) => v.filter((k) => k !== key)); clear(key); }}
onSelectValue={(filterKey, value) => apply(filterKey, value)}
/>
// A chip's own × only CLEARS its values; it stays in the toolbar. Removing
// it is a deliberate trip back to the menu, so clearing a filter you are
// still working with never makes the control vanish under the pointer.
// ── Both footer buttons commit and close ──────────────────────────────
// Apply commits the draft; Clear drops the filter. Clear is NOT "untick
// everything and carry on" — that leaves the panel open over a filter
// still in force, so the chip reads "Status 2" above a list showing
// nothing ticked, and closing the panel silently keeps the old value.
// Clear stays enabled whenever there is anything to drop, including an
// applied value whose draft has just been emptied by hand.
// ── Seven chips, one for each shape ───────────────────────────────────
// SelectFilterChip multi-select; search past 8 options, count on the pill
// SingleSelectFilterChip one-of-many; applies on pick, nothing to stage
// DateRangeFilterChip two typed date fields, optional relative tab
// CalendarDateFilterChip a real calendar, single or range, optional presets
// NumberRangeFilterChip min / max, optional currency prefix
// TextFilterChip one free-text value, Enter applies
// MonthRangeFilterChip start / end month on a year grid
// Pick between the two date chips by how people reach for the filter:
// typed fields for a known span, a calendar for "that Tuesday" or "the
// week of the 14th", where two text inputs make you count days.
<CalendarDateFilterChip
label="Settlement date"
value={date}
onChange={setDate}
// resolve() runs when the preset is CHOSEN, so "last 7 days" counts
// back from the click rather than from whenever this array was built.
presets={[
{ value: "today", label: "Today", resolve: () => lastDays(0) },
{ value: "last7", label: "Last 7 days", resolve: () => lastDays(6) },
]}
/>
// ── A relative date range ─────────────────────────────────────────────
// "Last 2 days" is a duration, not a pair of dates: resolving it when the
// user picks it quietly freezes it at that moment. The chip reports the
// duration and you resolve it at request time, where now is still now.
<DateRangeFilterChip
value={range}
onChange={setRange}
relativeValue={relative}
onRelativeChange={setRelative} // adding this turns on the "Last…" tab
/>
const window = relativeRangeToMillis(relative); // call in the handler
// The two modes are exclusive: applying one clears the other, so a
// request is never built from an absolute window AND a relative one.
// ── Excluding instead of including ────────────────────────────────────
// Supply onInvertChange and the panel grows an "Invert filter" tick. It
// is staged with the options and applied with them, because inverting
// without changing the set still changes what the table shows. An
// inverted chip says so on the pill — "Status (excluded)" — since the
// count alone would be identical either way.
<SelectFilterChip
label="Status"
options={STATUS}
selected={status}
onChange={setStatus}
invert={invert}
onInvertChange={setInvert}
/>
// ── A one-off chip ────────────────────────────────────────────────────
// Build on FilterChip rather than reassembling the shell, so a bespoke
// filter still opens, closes and clears like the rest. onOpen reseeds the
// draft from the applied value, so an abandoned edit never leaks back.
<FilterChip
chipKey="risk"
label="Risk score"
active={score !== ""}
onClear={() => setScore("")}
onOpen={() => setDraft(score)}
>
<RiskSlider value={draft} onChange={setDraft} />
<FilterChipActions onClear={() => setDraft("")} onApply={() => setScore(draft)} />
</FilterChip>