A composable master–detail filter panel: a rail of facets beside a detail pane of their controls — multi-select, numeric range, hierarchical tree, and boolean — built from structural parts over a headless, serializable state core that commits immediately or on an explicit Apply. There is no schema; every facet and control is composed from children.
Overview
Composition can drive a real product's entire transaction filter. Here a rail of thirteen facets — searchable grouped multi-selects, two hierarchical trees, a grouped account list, status checkboxes whose description sits under the label, recipient and card rows whose hint sits after it, a numeric amount range, a boolean flag, and an attachment radio — all share one serializable state, opened from a single "Filter" button. The two secondary-text placements are pure composition: an Option.Description stacks under the label, an Option.Meta pins to the row's end.
"use client";
import { Icon } from "@stridge/noctis";
import { Filters, createSerializer } from "@stridge/noctis/filters";
import type { SerializationContract } from "@stridge/noctis/filters";
import {
ArrowDownUp,
CircleDot,
CreditCard,
Gavel,
GitBranch,
Landmark,
List,
type LucideIcon,
Loader,
Paperclip,
Search,
Store,
Tag,
User,
} from "lucide-react";
/**
* A "super-advanced" showcase modelled 1:1 on a real product's transaction filter (Mercury): one rail of
* thirteen facets beside a detail pane, every control composed from the same structural parts — searchable
* grouped multi-selects, two hierarchical trees, a grouped account list, status checkboxes whose sub-text
* sits UNDER the label, recipient/card rows whose hint sits AFTER the label, a numeric amount range, a
* boolean flag, and an attachment radio — over one schemaless, serializable state. The tiny serializer
* contract (id → value type) is the only place a "type" is written; the controls imply their own on the
* client. The two secondary-text placements are just composition: `Option.Description` (a sibling stacked
* under the label) versus `Option.Meta` (a sibling pinned to the row's end). Under `applyMode="manual"`,
* edits stage as a draft and commit from the `Filters.Footer` (Apply / Reset / Clear all).
*/
const serializer = createSerializer({
keyword: "multiSelect",
amount: "number",
method: "tree",
category: "multiSelect",
merchantType: "multiSelect",
glCode: "multiSelect",
account: "multiSelect",
teamMember: "multiSelect",
department: "tree",
card: "multiSelect",
status: "multiSelect",
policy: "boolean",
attachment: "single",
} satisfies SerializationContract);
/** The rail's facets, in order — each links (by `facet` id) to the matching `Filters.Facet` panel. */
const RAIL: { facet: string; label: string; icon: LucideIcon }[] = [
{ facet: "keyword", label: "Keyword", icon: Search },
{ facet: "amount", label: "Amount", icon: ArrowDownUp },
{ facet: "method", label: "Method", icon: CircleDot },
{ facet: "category", label: "Category", icon: Tag },
{ facet: "merchantType", label: "Merchant Type", icon: Store },
{ facet: "glCode", label: "GL Code", icon: List },
{ facet: "account", label: "Account", icon: Landmark },
{ facet: "teamMember", label: "Team Member", icon: User },
{ facet: "department", label: "Department", icon: GitBranch },
{ facet: "card", label: "Card", icon: CreditCard },
{ facet: "status", label: "Status", icon: Loader },
{ facet: "policy", label: "Policy", icon: Gavel },
{ facet: "attachment", label: "Attachment", icon: Paperclip },
];
interface OptionData {
value: string;
label: string;
/** A trailing hint after the label (an entity type or a card nickname). */
meta?: string;
}
const recipients: OptionData[] = [
{ value: "contractor", label: "Contractor" },
{ value: "lighthouse", label: "Lighthouse Properties #3431" },
{ value: "domestic-ads", label: "Domestic Ads" },
{ value: "debug-llc", label: "Debug LLC" },
{ value: "nutritionist", label: "Nutritionist" },
{ value: "jordi", label: "Jordi O'Donnell" },
{ value: "jean", label: "Jean Vallee" },
{ value: "catherine", label: "Catherine Ndereba" },
];
const categories: OptionData[] = [
{ value: "business-meals", label: "Business Client Meals" },
{ value: "contractor-payments", label: "Contractor Payments" },
{ value: "employee-gifts", label: "Employee Gifts" },
{ value: "investments", label: "Investments" },
{ value: "legal-fees", label: "Legal Fees" },
{ value: "lunch-perks", label: "Lunch Perks" },
{ value: "office-supplies", label: "Office Supplies" },
{ value: "software", label: "Software" },
{ value: "travel-accommodation", label: "Travel — Accommodation" },
{ value: "travel-flights", label: "Travel — Flights" },
];
const merchantTypes: OptionData[] = [
{ value: "advertising", label: "Advertising" },
{ value: "airlines", label: "Airlines" },
{ value: "alcohol", label: "Alcohol and Bars" },
{ value: "books", label: "Books and Newspapers" },
{ value: "car-rental", label: "Car Rental" },
{ value: "charity", label: "Charity" },
{ value: "clothing", label: "Clothing" },
{ value: "conferences", label: "Conferences" },
{ value: "education", label: "Education" },
{ value: "electronics", label: "Electronics" },
{ value: "entertainment", label: "Entertainment" },
{ value: "facilities", label: "Facilities Expenses" },
];
const glCodes: OptionData[] = [
{ value: "7772", label: "Mercury Checking (7772)" },
{ value: "7774", label: "Mercury Savings (7774)" },
{ value: "120", label: "120 — Accounts Receivable" },
{ value: "318", label: "318 — Unbilled Receivables" },
{ value: "400", label: "400 — Inventory" },
{ value: "782", label: "782 — Expense Account" },
{ value: "734", label: "734 — COGS" },
];
const teamMembers: OptionData[] = [
{ value: "jane", label: "Jane Black" },
{ value: "abigail", label: "Abigail Kyte" },
{ value: "alice", label: "Alice Chen" },
{ value: "andrew", label: "Andrew Jeffords" },
{ value: "anthony", label: "Anthony Buteo" },
{ value: "brian", label: "Brian Ford" },
{ value: "brock", label: "Brock Forestead" },
{ value: "bryce", label: "Bryce Clay" },
{ value: "carly", label: "Carly Avines" },
];
const mercuryAccounts: OptionData[] = [
{ value: "acct-credit-card", label: "Credit Card" },
{ value: "acct-treasury", label: "Treasury" },
{ value: "acct-ops", label: "Ops / Payroll" },
{ value: "acct-ap", label: "AP" },
{ value: "acct-checking-0297", label: "Checking ••0297" },
{ value: "acct-savings-7658", label: "Savings ••7658" },
];
const linkedAccounts: OptionData[] = [
{ value: "acct-stripe", label: "Stripe Income – Mercury Demo, Inc" },
{ value: "acct-savings-6789", label: "Savings ••6789 – Jane's Account" },
{ value: "acct-checking-9876", label: "Checking ••9876 – Iron Bank" },
{ value: "acct-chase-cc", label: "Chase Credit Card – Chase" },
];
const yourCards: OptionData[] = [
{ value: "3745", label: "Jane B. ••3745" },
{ value: "4928", label: "Jane B. ••4928", meta: "Grocery/Meals" },
{ value: "6112", label: "Jane B. ••6112", meta: "Column Card" },
{ value: "0330", label: "Jane B. ••0330", meta: "AWS billing" },
{ value: "3054", label: "Jane B. ••3054", meta: "Facebook ads" },
{ value: "8628", label: "Jane B. ••8628", meta: "Travel expenses" },
];
const canceledCards: OptionData[] = [
{ value: "8675", label: "Jane B. ••8675", meta: "AWS Card" },
{ value: "9876", label: "Jane B. ••9876" },
{ value: "9999", label: "Jane B. ••9999", meta: "Old lunch card" },
];
const statuses = [
{ value: "posted", label: "Posted", description: "Money was successfully transferred." },
{
value: "pending",
label: "Pending",
description: "A transfer has been initiated, and a hold may be placed on your account, but it has not completed.",
},
{ value: "failed", label: "Failed", description: "No money was transferred." },
{
value: "canceled",
label: "Canceled",
description: "Transaction was stopped by you, a teammate, or a Mercury employee before money was transferred.",
},
{ value: "blocked", label: "Blocked", description: "Vendor payment was blocked." },
];
/** One multi-select row: the box, the label, and (optionally) a trailing meta hint after the label. */
function OptionRow({ value, label, meta }: OptionData) {
return (
<Filters.Option value={value}>
<Filters.Option.Indicator />
<Filters.Option.Label>{label}</Filters.Option.Label>
{meta === undefined ? null : <Filters.Option.Meta>{meta}</Filters.Option.Meta>}
</Filters.Option>
);
}
export default function FiltersTransactions() {
return (
<Filters.Root serializer={serializer} applyMode="manual">
<Filters.Menu defaultFacet="keyword">
<Filters.MenuTrigger>
<Filters.TriggerIcon />
<Filters.TriggerLabel>Filter</Filters.TriggerLabel>
<Filters.Count />
</Filters.MenuTrigger>
<Filters.MenuPanel>
<Filters.Header>
<Filters.HeaderTitle>Filters</Filters.HeaderTitle>
<Filters.Clear>Reset</Filters.Clear>
</Filters.Header>
<Filters.Rail>
{RAIL.map((item) => (
<Filters.RailItem key={item.facet} facet={item.facet}>
<Filters.RailItem.Icon>
<Icon icon={item.icon} size="sm" />
</Filters.RailItem.Icon>
<Filters.RailItem.Label>{item.label}</Filters.RailItem.Label>
<Filters.RailItem.Count />
<Filters.RailItem.Chevron />
</Filters.RailItem>
))}
</Filters.Rail>
<Filters.Detail>
{/* Keyword — a searchable, grouped multi-select; each row carries a trailing "Recipient" meta. */}
<Filters.Facet facet="keyword">
<Filters.Search placeholder="Search for merchants, recipients…" />
<Filters.CheckboxOptions>
<Filters.Group>
<Filters.GroupLabel>
<Filters.GroupTitle>Recent</Filters.GroupTitle>
<Filters.BulkActions>
<Filters.SelectAll />
<Filters.Inverse />
<Filters.None />
</Filters.BulkActions>
</Filters.GroupLabel>
{recipients.map((option) => (
<OptionRow key={option.value} {...option} meta="Recipient" />
))}
</Filters.Group>
<Filters.Empty>{(query) => `No matches for "${query}"`}</Filters.Empty>
</Filters.CheckboxOptions>
</Filters.Facet>
{/* Amount — three labelled comparators (exact / at-least / at-most); the min/max pair
validates that the minimum never exceeds the maximum. */}
<Filters.Facet facet="amount">
<Filters.Range>
<Filters.Range.Comparator operator="eq" symbol="=" prefix="$">
Equals
</Filters.Range.Comparator>
<Filters.Range.Comparator operator="gte" symbol="≥" prefix="$">
At least
</Filters.Range.Comparator>
<Filters.Range.Comparator operator="lte" symbol="≤" prefix="$">
At most
</Filters.Range.Comparator>
<Filters.Range.Error />
</Filters.Range>
</Filters.Facet>
{/* Method — a searchable hierarchical tree with a Select all header; a parent is a
tri-state select-all over its leaves. */}
<Filters.Facet facet="method">
<Filters.Search placeholder="Search payment methods" />
<Filters.Tree>
<Filters.GroupLabel>
<Filters.GroupTitle>Method</Filters.GroupTitle>
<Filters.BulkActions>
<Filters.SelectAll />
<Filters.Inverse />
<Filters.None />
</Filters.BulkActions>
</Filters.GroupLabel>
<Filters.TreeItem value="ach">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>ACH</Filters.TreeItem.Label>
<Filters.TreeItem value="ach-mercury">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Mercury Payment (ACH)</Filters.TreeItem.Label>
</Filters.TreeItem>
<Filters.TreeItem value="ach-in">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>ACH In</Filters.TreeItem.Label>
</Filters.TreeItem>
<Filters.TreeItem value="ach-pull">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>ACH Pull</Filters.TreeItem.Label>
</Filters.TreeItem>
</Filters.TreeItem>
<Filters.TreeItem value="cards">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Cards</Filters.TreeItem.Label>
<Filters.TreeItem value="debit-card">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Debit Card</Filters.TreeItem.Label>
</Filters.TreeItem>
<Filters.TreeItem value="credit-card">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Credit Card</Filters.TreeItem.Label>
</Filters.TreeItem>
<Filters.TreeItem value="intl-fee">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Intl. Transaction Fee</Filters.TreeItem.Label>
</Filters.TreeItem>
<Filters.TreeItem value="cashback">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Credit Cashback Deposit</Filters.TreeItem.Label>
</Filters.TreeItem>
</Filters.TreeItem>
<Filters.TreeItem value="check">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Check</Filters.TreeItem.Label>
<Filters.TreeItem value="check-payment">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Check Payment</Filters.TreeItem.Label>
</Filters.TreeItem>
<Filters.TreeItem value="check-deposit">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Check Deposit</Filters.TreeItem.Label>
</Filters.TreeItem>
</Filters.TreeItem>
<Filters.Empty>{(query) => `No matches for "${query}"`}</Filters.Empty>
</Filters.Tree>
</Filters.Facet>
{/* Category — a plain searchable multi-select. */}
<Filters.Facet facet="category">
<Filters.Search placeholder="Search for a category" />
<Filters.CheckboxOptions>
{categories.map((option) => (
<OptionRow key={option.value} {...option} />
))}
<Filters.Empty>{(query) => `No matches for "${query}"`}</Filters.Empty>
</Filters.CheckboxOptions>
</Filters.Facet>
{/* Merchant Type — a plain searchable multi-select. */}
<Filters.Facet facet="merchantType">
<Filters.Search placeholder="Search for a merchant type" />
<Filters.CheckboxOptions>
{merchantTypes.map((option) => (
<OptionRow key={option.value} {...option} />
))}
<Filters.Empty>{(query) => `No matches for "${query}"`}</Filters.Empty>
</Filters.CheckboxOptions>
</Filters.Facet>
{/* GL Code — a plain searchable multi-select. */}
<Filters.Facet facet="glCode">
<Filters.Search placeholder="Search for a GL Code" />
<Filters.CheckboxOptions>
{glCodes.map((option) => (
<OptionRow key={option.value} {...option} />
))}
<Filters.Empty>{(query) => `No matches for "${query}"`}</Filters.Empty>
</Filters.CheckboxOptions>
</Filters.Facet>
{/* Account — a grouped list, each section with its own Select all. */}
<Filters.Facet facet="account">
<Filters.CheckboxOptions>
<Filters.Group>
<Filters.GroupLabel>
<Filters.GroupTitle>Mercury accounts</Filters.GroupTitle>
<Filters.BulkActions>
<Filters.SelectAll />
<Filters.Inverse />
<Filters.None />
</Filters.BulkActions>
</Filters.GroupLabel>
{mercuryAccounts.map((option) => (
<OptionRow key={option.value} {...option} />
))}
</Filters.Group>
<Filters.Group>
<Filters.GroupLabel>
<Filters.GroupTitle>Linked accounts</Filters.GroupTitle>
<Filters.BulkActions>
<Filters.SelectAll />
<Filters.Inverse />
<Filters.None />
</Filters.BulkActions>
</Filters.GroupLabel>
{linkedAccounts.map((option) => (
<OptionRow key={option.value} {...option} />
))}
</Filters.Group>
</Filters.CheckboxOptions>
</Filters.Facet>
{/* Team Member — a plain searchable multi-select. */}
<Filters.Facet facet="teamMember">
<Filters.Search placeholder="Search team members by name or email" />
<Filters.CheckboxOptions>
{teamMembers.map((option) => (
<OptionRow key={option.value} {...option} />
))}
<Filters.Empty>{(query) => `No matches for "${query}"`}</Filters.Empty>
</Filters.CheckboxOptions>
</Filters.Facet>
{/* Department — a second searchable tree. */}
<Filters.Facet facet="department">
<Filters.Search placeholder="Search departments" />
<Filters.Tree>
<Filters.GroupLabel>
<Filters.GroupTitle>Department</Filters.GroupTitle>
<Filters.BulkActions>
<Filters.SelectAll />
<Filters.Inverse />
<Filters.None />
</Filters.BulkActions>
</Filters.GroupLabel>
<Filters.TreeItem value="executive">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Executive</Filters.TreeItem.Label>
</Filters.TreeItem>
<Filters.TreeItem value="finance">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Finance</Filters.TreeItem.Label>
<Filters.TreeItem value="accounting">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Accounting</Filters.TreeItem.Label>
</Filters.TreeItem>
<Filters.TreeItem value="audit">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Audit</Filters.TreeItem.Label>
</Filters.TreeItem>
</Filters.TreeItem>
<Filters.TreeItem value="product">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Product</Filters.TreeItem.Label>
<Filters.TreeItem value="design">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Design</Filters.TreeItem.Label>
</Filters.TreeItem>
</Filters.TreeItem>
<Filters.Empty>{(query) => `No matches for "${query}"`}</Filters.Empty>
</Filters.Tree>
</Filters.Facet>
{/* Card — a searchable, grouped multi-select whose rows carry a trailing card-nickname meta. */}
<Filters.Facet facet="card">
<Filters.Search placeholder="Search cardholders" />
<Filters.CheckboxOptions>
<Filters.Group>
<Filters.GroupLabel>
<Filters.GroupTitle>Your cards</Filters.GroupTitle>
<Filters.BulkActions>
<Filters.SelectAll />
<Filters.Inverse />
<Filters.None />
</Filters.BulkActions>
</Filters.GroupLabel>
{yourCards.map((option) => (
<OptionRow key={option.value} {...option} />
))}
</Filters.Group>
<Filters.Group>
<Filters.GroupLabel>
<Filters.GroupTitle>Canceled cards</Filters.GroupTitle>
</Filters.GroupLabel>
{canceledCards.map((option) => (
<OptionRow key={option.value} {...option} />
))}
</Filters.Group>
<Filters.Empty>{(query) => `No matches for "${query}"`}</Filters.Empty>
</Filters.CheckboxOptions>
</Filters.Facet>
{/* Status — checkboxes whose description sits UNDER the label (a sibling in the text column). */}
<Filters.Facet facet="status">
<Filters.CheckboxOptions>
{statuses.map((status) => (
<Filters.Option key={status.value} value={status.value}>
<Filters.Option.Indicator />
<Filters.Option.Label>{status.label}</Filters.Option.Label>
<Filters.Option.Description>{status.description}</Filters.Option.Description>
</Filters.Option>
))}
</Filters.CheckboxOptions>
</Filters.Facet>
{/* Policy — a single boolean flag with its own sub-text. */}
<Filters.Facet facet="policy">
<Filters.Boolean>
<Filters.Boolean.Indicator />
<Filters.Boolean.Label>Missing required information</Filters.Boolean.Label>
<Filters.Boolean.Description>
Card transactions missing receipts, notes, or categories.
</Filters.Boolean.Description>
</Filters.Boolean>
</Filters.Facet>
{/* Attachment — a single-select radio. */}
<Filters.Facet facet="attachment">
<Filters.RadioOptions>
<Filters.Option value="any">
<Filters.Option.Indicator />
<Filters.Option.Label>Any</Filters.Option.Label>
</Filters.Option>
<Filters.Option value="has">
<Filters.Option.Indicator />
<Filters.Option.Label>Has attachment</Filters.Option.Label>
</Filters.Option>
<Filters.Option value="none">
<Filters.Option.Indicator />
<Filters.Option.Label>Does not have</Filters.Option.Label>
</Filters.Option>
</Filters.RadioOptions>
</Filters.Facet>
</Filters.Detail>
{/* Manual apply: edits stage as a draft; the footer summarises the active count and commits
it. Clear / Reset live in the header, so the footer stays a summary + a single action. */}
<Filters.Footer>
<Filters.Summary>
{(count) => (count === 0 ? "No filters" : `${count} filter${count === 1 ? "" : "s"} active`)}
</Filters.Summary>
<Filters.Spacer />
<Filters.Apply>Apply</Filters.Apply>
</Filters.Footer>
</Filters.MenuPanel>
</Filters.Menu>
</Filters.Root>
);
}
Basic
Compose the menu under Filters.Root: a Filters.MenuTrigger (with TriggerIcon / TriggerLabel / Count), and a Filters.MenuPanel laying a Filters.Rail of Filters.RailItems beside a Filters.Detail of Filters.Facet panels — linked by a shared facet id, Tabs-style. Each facet composes a control: CheckboxOptions (multi-select), RadioOptions (single), Boolean (flag), Range (number), or Tree (hierarchy), built from structural parts like Filters.Option + Option.Indicator / Option.Label. In the default immediate mode, every edit commits as you make it.
"use client";
import { Icon } from "@stridge/noctis";
import { Filters } from "@stridge/noctis/filters";
import { CircleDot, Flag, Star } from "lucide-react";
/**
* The minimal composed panel: a rail of facets beside a detail pane. Each `Filters.RailItem` is the
* "trigger" (linked to its `Filters.Facet` panel by `facet`), and each control is composed from parts —
* `CheckboxOptions` for multi-select, `RadioOptions` for a single select, `Boolean` for a flag.
*/
export default function FiltersBasic() {
return (
<Filters.Root>
<Filters.Menu defaultFacet="status">
<Filters.MenuTrigger>
<Filters.TriggerIcon />
<Filters.TriggerLabel>Filter</Filters.TriggerLabel>
<Filters.Count />
</Filters.MenuTrigger>
<Filters.MenuPanel>
<Filters.Rail>
<Filters.RailItem facet="status">
<Filters.RailItem.Icon>
<Icon icon={CircleDot} size="sm" />
</Filters.RailItem.Icon>
<Filters.RailItem.Label>Status</Filters.RailItem.Label>
<Filters.RailItem.Count />
<Filters.RailItem.Chevron />
</Filters.RailItem>
<Filters.RailItem facet="priority">
<Filters.RailItem.Icon>
<Icon icon={Flag} size="sm" />
</Filters.RailItem.Icon>
<Filters.RailItem.Label>Priority</Filters.RailItem.Label>
<Filters.RailItem.Count />
<Filters.RailItem.Chevron />
</Filters.RailItem>
<Filters.RailItem facet="starred">
<Filters.RailItem.Icon>
<Icon icon={Star} size="sm" />
</Filters.RailItem.Icon>
<Filters.RailItem.Label>Starred</Filters.RailItem.Label>
<Filters.RailItem.Count />
<Filters.RailItem.Chevron />
</Filters.RailItem>
</Filters.Rail>
<Filters.Detail>
<Filters.Facet facet="status">
<Filters.CheckboxOptions>
<Filters.Option value="open">
<Filters.Option.Indicator />
<Filters.Option.Label>Open</Filters.Option.Label>
<Filters.Option.Count>128</Filters.Option.Count>
</Filters.Option>
<Filters.Option value="in-progress">
<Filters.Option.Indicator />
<Filters.Option.Label>In progress</Filters.Option.Label>
<Filters.Option.Count>34</Filters.Option.Count>
</Filters.Option>
<Filters.Option value="closed">
<Filters.Option.Indicator />
<Filters.Option.Label>Closed</Filters.Option.Label>
<Filters.Option.Count>512</Filters.Option.Count>
</Filters.Option>
</Filters.CheckboxOptions>
</Filters.Facet>
<Filters.Facet facet="priority">
<Filters.RadioOptions>
<Filters.Option value="low">
<Filters.Option.Indicator />
<Filters.Option.Label>Low</Filters.Option.Label>
</Filters.Option>
<Filters.Option value="medium">
<Filters.Option.Indicator />
<Filters.Option.Label>Medium</Filters.Option.Label>
</Filters.Option>
<Filters.Option value="high">
<Filters.Option.Indicator />
<Filters.Option.Label>High</Filters.Option.Label>
</Filters.Option>
</Filters.RadioOptions>
</Filters.Facet>
<Filters.Facet facet="starred">
<Filters.Boolean>
<Filters.Boolean.Indicator />
<Filters.Boolean.Label>Starred only</Filters.Boolean.Label>
</Filters.Boolean>
</Filters.Facet>
</Filters.Detail>
</Filters.MenuPanel>
</Filters.Menu>
</Filters.Root>
);
}
Numeric range
A number field offers a comparison operator (equals, greater-than, between…) picked from Filters.OperatorSelect, with one or two inputs following the operator's arity. Add a prefix for a unit or currency glyph and min/max bounds.
"use client";
import { Icon } from "@stridge/noctis";
import { Filters } from "@stridge/noctis/filters";
import { ArrowDownUp } from "lucide-react";
/**
* The numeric-comparison facet — compose `Range.Operator` (the comparator picker) with `Range.Min` /
* `Range.Max`. A scalar comparator (`equals`, `greater than`, …) uses `Range.Min` only; `between` uses
* both. The control owns the `number` value type.
*/
export default function FiltersNumberRange() {
return (
<Filters.Root>
<Filters.Menu defaultFacet="amount">
<Filters.MenuTrigger>
<Filters.TriggerIcon />
<Filters.TriggerLabel>Filter</Filters.TriggerLabel>
<Filters.Count />
</Filters.MenuTrigger>
<Filters.MenuPanel>
<Filters.Rail>
<Filters.RailItem facet="amount">
<Filters.RailItem.Icon>
<Icon icon={ArrowDownUp} size="sm" />
</Filters.RailItem.Icon>
<Filters.RailItem.Label>Amount</Filters.RailItem.Label>
<Filters.RailItem.Count />
<Filters.RailItem.Chevron />
</Filters.RailItem>
</Filters.Rail>
<Filters.Detail>
<Filters.Facet facet="amount">
<Filters.Range>
<Filters.Range.Operator />
<Filters.Range.Min prefix="$" />
<Filters.Range.Max prefix="$" />
</Filters.Range>
</Filters.Facet>
</Filters.Detail>
</Filters.MenuPanel>
</Filters.Menu>
</Filters.Root>
);
}
Hierarchical tree
A hierarchical field builds a tree from flat options with parent back-references. A parent is a tri-state select-all over its descendant leaves — checking it selects every leaf beneath, and it reads mixed when only some are selected. Only leaf values are stored; parents are derived. Long trees virtualize.
"use client";
import { Icon } from "@stridge/noctis";
import { Filters } from "@stridge/noctis/filters";
import { GitBranch } from "lucide-react";
/**
* The hierarchical tree facet — nested `Filters.TreeItem`s. A parent is a tri-state select-all over its
* descendant leaves: checking it selects every leaf beneath, and it reads `mixed` when only some are.
* Only leaf values are stored; parents are derived.
*/
export default function FiltersTree() {
return (
<Filters.Root>
<Filters.Menu defaultFacet="method">
<Filters.MenuTrigger>
<Filters.TriggerIcon />
<Filters.TriggerLabel>Filter</Filters.TriggerLabel>
<Filters.Count />
</Filters.MenuTrigger>
<Filters.MenuPanel>
<Filters.Rail>
<Filters.RailItem facet="method">
<Filters.RailItem.Icon>
<Icon icon={GitBranch} size="sm" />
</Filters.RailItem.Icon>
<Filters.RailItem.Label>Method</Filters.RailItem.Label>
<Filters.RailItem.Count />
<Filters.RailItem.Chevron />
</Filters.RailItem>
</Filters.Rail>
<Filters.Detail>
<Filters.Facet facet="method">
<Filters.Tree>
<Filters.TreeItem value="card">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Card</Filters.TreeItem.Label>
<Filters.TreeItem value="credit">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Credit</Filters.TreeItem.Label>
</Filters.TreeItem>
<Filters.TreeItem value="debit">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Debit</Filters.TreeItem.Label>
</Filters.TreeItem>
</Filters.TreeItem>
<Filters.TreeItem value="transfer">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Transfer</Filters.TreeItem.Label>
<Filters.TreeItem value="ach">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>ACH</Filters.TreeItem.Label>
</Filters.TreeItem>
<Filters.TreeItem value="wire">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Wire</Filters.TreeItem.Label>
</Filters.TreeItem>
</Filters.TreeItem>
</Filters.Tree>
</Filters.Facet>
</Filters.Detail>
</Filters.MenuPanel>
</Filters.Menu>
</Filters.Root>
);
}
Apply modes
The default immediate mode commits every edit live. Set applyMode="manual" to stage edits as a draft and commit them only on an explicit action — add a Filters.Footer for Apply, Reset (revert to the applied state), and Clear all. It mirrors Filters.Header, framing the panes as a matching bar.
"use client";
import { Icon } from "@stridge/noctis";
import { Filters } from "@stridge/noctis/filters";
import { CircleDot, Flag } from "lucide-react";
/**
* `applyMode="manual"` stages edits as a draft; the composed `Filters.Footer` commits (`Filters.Apply`),
* reverts (`Filters.Revert`), or empties (`Filters.Clear`). It mirrors the `Filters.Header` as a matching
* bar. The default `"immediate"` mode commits every edit live and needs no footer.
*/
export default function FiltersApplyModes() {
return (
<Filters.Root applyMode="manual">
<Filters.Menu defaultFacet="status">
<Filters.MenuTrigger>
<Filters.TriggerIcon />
<Filters.TriggerLabel>Filter</Filters.TriggerLabel>
<Filters.Count />
</Filters.MenuTrigger>
<Filters.MenuPanel>
<Filters.Header>
<Filters.HeaderTitle>Filters</Filters.HeaderTitle>
<Filters.Clear>Reset</Filters.Clear>
</Filters.Header>
<Filters.Rail>
<Filters.RailItem facet="status">
<Filters.RailItem.Icon>
<Icon icon={CircleDot} size="sm" />
</Filters.RailItem.Icon>
<Filters.RailItem.Label>Status</Filters.RailItem.Label>
<Filters.RailItem.Count />
<Filters.RailItem.Chevron />
</Filters.RailItem>
<Filters.RailItem facet="priority">
<Filters.RailItem.Icon>
<Icon icon={Flag} size="sm" />
</Filters.RailItem.Icon>
<Filters.RailItem.Label>Priority</Filters.RailItem.Label>
<Filters.RailItem.Count />
<Filters.RailItem.Chevron />
</Filters.RailItem>
</Filters.Rail>
<Filters.Detail>
<Filters.Facet facet="status">
<Filters.CheckboxOptions>
<Filters.Option value="open">
<Filters.Option.Indicator />
<Filters.Option.Label>Open</Filters.Option.Label>
</Filters.Option>
<Filters.Option value="closed">
<Filters.Option.Indicator />
<Filters.Option.Label>Closed</Filters.Option.Label>
</Filters.Option>
</Filters.CheckboxOptions>
</Filters.Facet>
<Filters.Facet facet="priority">
<Filters.RadioOptions>
<Filters.Option value="low">
<Filters.Option.Indicator />
<Filters.Option.Label>Low</Filters.Option.Label>
</Filters.Option>
<Filters.Option value="high">
<Filters.Option.Indicator />
<Filters.Option.Label>High</Filters.Option.Label>
</Filters.Option>
</Filters.RadioOptions>
</Filters.Facet>
</Filters.Detail>
<Filters.Footer>
<Filters.Clear>Clear all</Filters.Clear>
<Filters.Spacer />
<Filters.Revert>Reset</Filters.Revert>
<Filters.Apply>Apply</Filters.Apply>
</Filters.Footer>
</Filters.MenuPanel>
</Filters.Menu>
</Filters.Root>
);
}
Standalone facet
A facet need not live in the menu. Mark a field pinned and render a Filters.QuickFilter — a chip that opens its own popover over a single facet's control, for an always-visible toolbar filter. It shares the same state as the menu.
"use client";
import { Icon } from "@stridge/noctis";
import { Filters } from "@stridge/noctis/filters";
import { CircleDot } from "lucide-react";
/**
* A `Filters.QuickFilter` is a pinned chip that opens its own popover — one facet promoted out of the
* menu for an always-visible toolbar control. It reads and writes the same state as the menu; the popover
* body is a composed control.
*/
export default function FiltersStandaloneFacet() {
return (
<Filters.Root>
<Filters.QuickFilter facet="status" label="Status" icon={<Icon icon={CircleDot} size="sm" />}>
<Filters.CheckboxOptions>
<Filters.Option value="open">
<Filters.Option.Indicator />
<Filters.Option.Label>Open</Filters.Option.Label>
<Filters.Option.Count>128</Filters.Option.Count>
</Filters.Option>
<Filters.Option value="in-progress">
<Filters.Option.Indicator />
<Filters.Option.Label>In progress</Filters.Option.Label>
<Filters.Option.Count>34</Filters.Option.Count>
</Filters.Option>
<Filters.Option value="closed">
<Filters.Option.Indicator />
<Filters.Option.Label>Closed</Filters.Option.Label>
<Filters.Option.Count>512</Filters.Option.Count>
</Filters.Option>
</Filters.CheckboxOptions>
</Filters.QuickFilter>
</Filters.Root>
);
}
URL state
The applied state is serializable — ids and primitives only — so it round-trips through the URL. A FilterSerializer maps it to and from search params; the default one produces readable per-field params (?status=in:open,closed) and validates untrusted input against the schema. The framework-neutral serializer + onUrlChange seam is shown below.
?—"use client";
import { Icon } from "@stridge/noctis";
import { createSerializer, Filters } from "@stridge/noctis/filters";
import { CircleDot } from "lucide-react";
import { useMemo, useState } from "react";
/**
* A serializer round-trips the applied state to and from URL search params. `createSerializer` takes a
* tiny `{ id: valueType }` contract (not a schema) — the only place a value type is written. The
* framework-neutral seam (`serializer` + `onUrlChange`) shows the query string live; in an app you apply
* it to the router, or use `@stridge/noctis/filters/nuqs` for a wired hook and an RSC-safe `loadFilters`.
*/
export default function FiltersUrlState() {
const serializer = useMemo(() => createSerializer({ status: "multiSelect" }), []);
const [query, setQuery] = useState("");
return (
<div className="flex flex-col items-center gap-3">
<Filters.Root serializer={serializer} onUrlChange={(params) => setQuery(new URLSearchParams(params).toString())}>
<Filters.Menu defaultFacet="status">
<Filters.MenuTrigger>
<Filters.TriggerIcon />
<Filters.TriggerLabel>Filter</Filters.TriggerLabel>
<Filters.Count />
</Filters.MenuTrigger>
<Filters.MenuPanel>
<Filters.Rail>
<Filters.RailItem facet="status">
<Filters.RailItem.Icon>
<Icon icon={CircleDot} size="sm" />
</Filters.RailItem.Icon>
<Filters.RailItem.Label>Status</Filters.RailItem.Label>
<Filters.RailItem.Count />
<Filters.RailItem.Chevron />
</Filters.RailItem>
</Filters.Rail>
<Filters.Detail>
<Filters.Facet facet="status">
<Filters.CheckboxOptions>
<Filters.Option value="open">
<Filters.Option.Indicator />
<Filters.Option.Label>Open</Filters.Option.Label>
</Filters.Option>
<Filters.Option value="closed">
<Filters.Option.Indicator />
<Filters.Option.Label>Closed</Filters.Option.Label>
</Filters.Option>
</Filters.CheckboxOptions>
</Filters.Facet>
</Filters.Detail>
</Filters.MenuPanel>
</Filters.Menu>
</Filters.Root>
<code className="max-w-full overflow-x-auto rounded-md bg-surface p-3 text-sm">?{query || "—"}</code>
</div>
);
}
For Next.js, @stridge/noctis/filters/nuqs wires a client hook, and @stridge/noctis/filters/nuqs/server a Server Component loader that parses and validates the search params on the server — no client round-trip:
import { loadFilters } from "@stridge/noctis/filters/nuqs/server";
const load = loadFilters(schema);
export default async function Page({ searchParams }: { searchParams: Promise<Record<string, string>> }) {
const applied = await load(searchParams);
const issues = await queryIssues(applied);
return <IssuesView schema={schema} initial={applied} issues={issues} />;
}Controlled
Own the applied state with value + onValueChange on Filters.Root. The committed rules are a flat, serializable FilterState you can feed straight into a query. onApply fires on each commit, and a ref exposes imperative apply / reset / clear / getState / getWhere.
[
{
"field": "status",
"operator": "in",
"value": [
"open"
]
}
]"use client";
import { Icon } from "@stridge/noctis";
import { Filters } from "@stridge/noctis/filters";
import type { FilterState } from "@stridge/noctis/filters";
import { CircleDot } from "lucide-react";
import { useState } from "react";
/**
* Drive the applied state yourself with `value` + `onValueChange`. The committed rules are serializable
* (ids and primitives only), so they map straight onto a query. `onApply` fires on each commit.
*/
export default function FiltersControlled() {
const [applied, setApplied] = useState<FilterState>([{ field: "status", operator: "in", value: ["open"] }]);
return (
<div className="flex flex-col items-center gap-3">
<Filters.Root value={applied} onValueChange={setApplied}>
<Filters.Menu defaultFacet="status">
<Filters.MenuTrigger>
<Filters.TriggerIcon />
<Filters.TriggerLabel>Filter</Filters.TriggerLabel>
<Filters.Count />
</Filters.MenuTrigger>
<Filters.MenuPanel>
<Filters.Rail>
<Filters.RailItem facet="status">
<Filters.RailItem.Icon>
<Icon icon={CircleDot} size="sm" />
</Filters.RailItem.Icon>
<Filters.RailItem.Label>Status</Filters.RailItem.Label>
<Filters.RailItem.Count />
<Filters.RailItem.Chevron />
</Filters.RailItem>
</Filters.Rail>
<Filters.Detail>
<Filters.Facet facet="status">
<Filters.CheckboxOptions>
<Filters.Option value="open">
<Filters.Option.Indicator />
<Filters.Option.Label>Open</Filters.Option.Label>
</Filters.Option>
<Filters.Option value="closed">
<Filters.Option.Indicator />
<Filters.Option.Label>Closed</Filters.Option.Label>
</Filters.Option>
</Filters.CheckboxOptions>
</Filters.Facet>
</Filters.Detail>
</Filters.MenuPanel>
</Filters.Menu>
</Filters.Root>
<pre className="max-w-full overflow-x-auto rounded-md bg-surface p-3 text-sm">{JSON.stringify(applied, null, 2)}</pre>
</div>
);
}
Mobile sheet
On small screens, swap the popover for Filters.Sheet — the same Rail / Detail / Footer parts hosted in a full-screen sheet as a two-level drill-down: the rail is the first screen (a full-width category list), tapping a category drills into its options, and the title bar's back affordance returns to the list. The title bar is the same composable Filters.Header — Filters.SheetBack / Filters.SheetTitle / Filters.SheetClose around any actions (typically a Filters.Clear) — leaving the pinned footer to a full-width Apply. Applying commits the draft and dismisses the sheet in one gesture (closeOnApply={false} keeps it open). The demo hosts the full transactions showcase — the same thirteen facets, controls, and serializable state as above, with only the shell swapped. Render both frames and show one per breakpoint.
"use client";
import { Icon } from "@stridge/noctis";
import { Filters, createSerializer } from "@stridge/noctis/filters";
import type { SerializationContract } from "@stridge/noctis/filters";
import {
ArrowDownUp,
CircleDot,
CreditCard,
Gavel,
GitBranch,
Landmark,
List,
type LucideIcon,
Loader,
Paperclip,
Search,
Store,
Tag,
User,
} from "lucide-react";
/**
* The transactions filter, hosted in the mobile frame: the SAME thirteen facets, controls, and
* serializable state as the desktop showcase, with only the shell swapped — `Filters.Sheet` drills
* between the category list and one facet at a time instead of showing both panes. The title bar is the
* composed `Filters.Header` (`Filters.SheetBack` / `Filters.SheetTitle` / a `Filters.Clear` beside
* `Filters.SheetClose`), and under `applyMode="manual"` the pinned footer's full-width Apply commits the
* draft and dismisses the sheet in one gesture.
*/
const serializer = createSerializer({
keyword: "multiSelect",
amount: "number",
method: "tree",
category: "multiSelect",
merchantType: "multiSelect",
glCode: "multiSelect",
account: "multiSelect",
teamMember: "multiSelect",
department: "tree",
card: "multiSelect",
status: "multiSelect",
policy: "boolean",
attachment: "single",
} satisfies SerializationContract);
/** The rail's facets, in order — each links (by `facet` id) to the matching `Filters.Facet` panel. */
const RAIL: { facet: string; label: string; icon: LucideIcon }[] = [
{ facet: "keyword", label: "Keyword", icon: Search },
{ facet: "amount", label: "Amount", icon: ArrowDownUp },
{ facet: "method", label: "Method", icon: CircleDot },
{ facet: "category", label: "Category", icon: Tag },
{ facet: "merchantType", label: "Merchant Type", icon: Store },
{ facet: "glCode", label: "GL Code", icon: List },
{ facet: "account", label: "Account", icon: Landmark },
{ facet: "teamMember", label: "Team Member", icon: User },
{ facet: "department", label: "Department", icon: GitBranch },
{ facet: "card", label: "Card", icon: CreditCard },
{ facet: "status", label: "Status", icon: Loader },
{ facet: "policy", label: "Policy", icon: Gavel },
{ facet: "attachment", label: "Attachment", icon: Paperclip },
];
interface OptionData {
value: string;
label: string;
/** A trailing hint after the label (an entity type or a card nickname). */
meta?: string;
}
const recipients: OptionData[] = [
{ value: "contractor", label: "Contractor" },
{ value: "lighthouse", label: "Lighthouse Properties #3431" },
{ value: "domestic-ads", label: "Domestic Ads" },
{ value: "debug-llc", label: "Debug LLC" },
{ value: "nutritionist", label: "Nutritionist" },
{ value: "jordi", label: "Jordi O'Donnell" },
{ value: "jean", label: "Jean Vallee" },
{ value: "catherine", label: "Catherine Ndereba" },
];
const categories: OptionData[] = [
{ value: "business-meals", label: "Business Client Meals" },
{ value: "contractor-payments", label: "Contractor Payments" },
{ value: "employee-gifts", label: "Employee Gifts" },
{ value: "investments", label: "Investments" },
{ value: "legal-fees", label: "Legal Fees" },
{ value: "lunch-perks", label: "Lunch Perks" },
{ value: "office-supplies", label: "Office Supplies" },
{ value: "software", label: "Software" },
{ value: "travel-accommodation", label: "Travel — Accommodation" },
{ value: "travel-flights", label: "Travel — Flights" },
];
const merchantTypes: OptionData[] = [
{ value: "advertising", label: "Advertising" },
{ value: "airlines", label: "Airlines" },
{ value: "alcohol", label: "Alcohol and Bars" },
{ value: "books", label: "Books and Newspapers" },
{ value: "car-rental", label: "Car Rental" },
{ value: "charity", label: "Charity" },
{ value: "clothing", label: "Clothing" },
{ value: "conferences", label: "Conferences" },
{ value: "education", label: "Education" },
{ value: "electronics", label: "Electronics" },
{ value: "entertainment", label: "Entertainment" },
{ value: "facilities", label: "Facilities Expenses" },
];
const glCodes: OptionData[] = [
{ value: "7772", label: "Mercury Checking (7772)" },
{ value: "7774", label: "Mercury Savings (7774)" },
{ value: "120", label: "120 — Accounts Receivable" },
{ value: "318", label: "318 — Unbilled Receivables" },
{ value: "400", label: "400 — Inventory" },
{ value: "782", label: "782 — Expense Account" },
{ value: "734", label: "734 — COGS" },
];
const teamMembers: OptionData[] = [
{ value: "jane", label: "Jane Black" },
{ value: "abigail", label: "Abigail Kyte" },
{ value: "alice", label: "Alice Chen" },
{ value: "andrew", label: "Andrew Jeffords" },
{ value: "anthony", label: "Anthony Buteo" },
{ value: "brian", label: "Brian Ford" },
{ value: "brock", label: "Brock Forestead" },
{ value: "bryce", label: "Bryce Clay" },
{ value: "carly", label: "Carly Avines" },
];
const mercuryAccounts: OptionData[] = [
{ value: "acct-credit-card", label: "Credit Card" },
{ value: "acct-treasury", label: "Treasury" },
{ value: "acct-ops", label: "Ops / Payroll" },
{ value: "acct-ap", label: "AP" },
{ value: "acct-checking-0297", label: "Checking ••0297" },
{ value: "acct-savings-7658", label: "Savings ••7658" },
];
const linkedAccounts: OptionData[] = [
{ value: "acct-stripe", label: "Stripe Income – Mercury Demo, Inc" },
{ value: "acct-savings-6789", label: "Savings ••6789 – Jane's Account" },
{ value: "acct-checking-9876", label: "Checking ••9876 – Iron Bank" },
{ value: "acct-chase-cc", label: "Chase Credit Card – Chase" },
];
const yourCards: OptionData[] = [
{ value: "3745", label: "Jane B. ••3745" },
{ value: "4928", label: "Jane B. ••4928", meta: "Grocery/Meals" },
{ value: "6112", label: "Jane B. ••6112", meta: "Column Card" },
{ value: "0330", label: "Jane B. ••0330", meta: "AWS billing" },
{ value: "3054", label: "Jane B. ••3054", meta: "Facebook ads" },
{ value: "8628", label: "Jane B. ••8628", meta: "Travel expenses" },
];
const canceledCards: OptionData[] = [
{ value: "8675", label: "Jane B. ••8675", meta: "AWS Card" },
{ value: "9876", label: "Jane B. ••9876" },
{ value: "9999", label: "Jane B. ••9999", meta: "Old lunch card" },
];
const statuses = [
{ value: "posted", label: "Posted", description: "Money was successfully transferred." },
{
value: "pending",
label: "Pending",
description: "A transfer has been initiated, and a hold may be placed on your account, but it has not completed.",
},
{ value: "failed", label: "Failed", description: "No money was transferred." },
{
value: "canceled",
label: "Canceled",
description: "Transaction was stopped by you, a teammate, or a Mercury employee before money was transferred.",
},
{ value: "blocked", label: "Blocked", description: "Vendor payment was blocked." },
];
/** One multi-select row: the box, the label, and (optionally) a trailing meta hint after the label. */
function OptionRow({ value, label, meta }: OptionData) {
return (
<Filters.Option value={value}>
<Filters.Option.Indicator />
<Filters.Option.Label>{label}</Filters.Option.Label>
{meta === undefined ? null : <Filters.Option.Meta>{meta}</Filters.Option.Meta>}
</Filters.Option>
);
}
export default function FiltersMobileSheet() {
return (
<Filters.Root serializer={serializer} applyMode="manual">
<Filters.Sheet defaultFacet="keyword">
<Filters.SheetTrigger />
<Filters.SheetPanel>
<Filters.Header>
<Filters.SheetBack />
<Filters.SheetTitle />
<Filters.Clear>Clear all</Filters.Clear>
<Filters.SheetClose />
</Filters.Header>
<Filters.Rail>
{RAIL.map((item) => (
<Filters.RailItem key={item.facet} facet={item.facet}>
<Filters.RailItem.Icon>
<Icon icon={item.icon} size="sm" />
</Filters.RailItem.Icon>
<Filters.RailItem.Label>{item.label}</Filters.RailItem.Label>
<Filters.RailItem.Count />
<Filters.RailItem.Chevron />
</Filters.RailItem>
))}
</Filters.Rail>
<Filters.Detail>
{/* Keyword — a searchable, grouped multi-select; each row carries a trailing "Recipient" meta. */}
<Filters.Facet facet="keyword">
<Filters.Search placeholder="Search for merchants, recipients…" />
<Filters.CheckboxOptions>
<Filters.Group>
<Filters.GroupLabel>
<Filters.GroupTitle>Recent</Filters.GroupTitle>
<Filters.BulkActions>
<Filters.SelectAll />
<Filters.Inverse />
<Filters.None />
</Filters.BulkActions>
</Filters.GroupLabel>
{recipients.map((option) => (
<OptionRow key={option.value} {...option} meta="Recipient" />
))}
</Filters.Group>
<Filters.Empty>{(query) => `No matches for "${query}"`}</Filters.Empty>
</Filters.CheckboxOptions>
</Filters.Facet>
{/* Amount — three labelled comparators (exact / at-least / at-most); the min/max pair
validates that the minimum never exceeds the maximum. */}
<Filters.Facet facet="amount">
<Filters.Range>
<Filters.Range.Comparator operator="eq" symbol="=" prefix="$">
Equals
</Filters.Range.Comparator>
<Filters.Range.Comparator operator="gte" symbol="≥" prefix="$">
At least
</Filters.Range.Comparator>
<Filters.Range.Comparator operator="lte" symbol="≤" prefix="$">
At most
</Filters.Range.Comparator>
<Filters.Range.Error />
</Filters.Range>
</Filters.Facet>
{/* Method — a searchable hierarchical tree with a Select all header; a parent is a
tri-state select-all over its leaves. */}
<Filters.Facet facet="method">
<Filters.Search placeholder="Search payment methods" />
<Filters.Tree>
<Filters.GroupLabel>
<Filters.GroupTitle>Method</Filters.GroupTitle>
<Filters.BulkActions>
<Filters.SelectAll />
<Filters.Inverse />
<Filters.None />
</Filters.BulkActions>
</Filters.GroupLabel>
<Filters.TreeItem value="ach">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>ACH</Filters.TreeItem.Label>
<Filters.TreeItem value="ach-mercury">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Mercury Payment (ACH)</Filters.TreeItem.Label>
</Filters.TreeItem>
<Filters.TreeItem value="ach-in">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>ACH In</Filters.TreeItem.Label>
</Filters.TreeItem>
<Filters.TreeItem value="ach-pull">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>ACH Pull</Filters.TreeItem.Label>
</Filters.TreeItem>
</Filters.TreeItem>
<Filters.TreeItem value="cards">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Cards</Filters.TreeItem.Label>
<Filters.TreeItem value="debit-card">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Debit Card</Filters.TreeItem.Label>
</Filters.TreeItem>
<Filters.TreeItem value="credit-card">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Credit Card</Filters.TreeItem.Label>
</Filters.TreeItem>
<Filters.TreeItem value="intl-fee">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Intl. Transaction Fee</Filters.TreeItem.Label>
</Filters.TreeItem>
<Filters.TreeItem value="cashback">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Credit Cashback Deposit</Filters.TreeItem.Label>
</Filters.TreeItem>
</Filters.TreeItem>
<Filters.TreeItem value="check">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Check</Filters.TreeItem.Label>
<Filters.TreeItem value="check-payment">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Check Payment</Filters.TreeItem.Label>
</Filters.TreeItem>
<Filters.TreeItem value="check-deposit">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Check Deposit</Filters.TreeItem.Label>
</Filters.TreeItem>
</Filters.TreeItem>
<Filters.Empty>{(query) => `No matches for "${query}"`}</Filters.Empty>
</Filters.Tree>
</Filters.Facet>
{/* Category — a plain searchable multi-select. */}
<Filters.Facet facet="category">
<Filters.Search placeholder="Search for a category" />
<Filters.CheckboxOptions>
{categories.map((option) => (
<OptionRow key={option.value} {...option} />
))}
<Filters.Empty>{(query) => `No matches for "${query}"`}</Filters.Empty>
</Filters.CheckboxOptions>
</Filters.Facet>
{/* Merchant Type — a plain searchable multi-select. */}
<Filters.Facet facet="merchantType">
<Filters.Search placeholder="Search for a merchant type" />
<Filters.CheckboxOptions>
{merchantTypes.map((option) => (
<OptionRow key={option.value} {...option} />
))}
<Filters.Empty>{(query) => `No matches for "${query}"`}</Filters.Empty>
</Filters.CheckboxOptions>
</Filters.Facet>
{/* GL Code — a plain searchable multi-select. */}
<Filters.Facet facet="glCode">
<Filters.Search placeholder="Search for a GL Code" />
<Filters.CheckboxOptions>
{glCodes.map((option) => (
<OptionRow key={option.value} {...option} />
))}
<Filters.Empty>{(query) => `No matches for "${query}"`}</Filters.Empty>
</Filters.CheckboxOptions>
</Filters.Facet>
{/* Account — a grouped list, each section with its own Select all. */}
<Filters.Facet facet="account">
<Filters.CheckboxOptions>
<Filters.Group>
<Filters.GroupLabel>
<Filters.GroupTitle>Mercury accounts</Filters.GroupTitle>
<Filters.BulkActions>
<Filters.SelectAll />
<Filters.Inverse />
<Filters.None />
</Filters.BulkActions>
</Filters.GroupLabel>
{mercuryAccounts.map((option) => (
<OptionRow key={option.value} {...option} />
))}
</Filters.Group>
<Filters.Group>
<Filters.GroupLabel>
<Filters.GroupTitle>Linked accounts</Filters.GroupTitle>
<Filters.BulkActions>
<Filters.SelectAll />
<Filters.Inverse />
<Filters.None />
</Filters.BulkActions>
</Filters.GroupLabel>
{linkedAccounts.map((option) => (
<OptionRow key={option.value} {...option} />
))}
</Filters.Group>
</Filters.CheckboxOptions>
</Filters.Facet>
{/* Team Member — a plain searchable multi-select. */}
<Filters.Facet facet="teamMember">
<Filters.Search placeholder="Search team members by name or email" />
<Filters.CheckboxOptions>
{teamMembers.map((option) => (
<OptionRow key={option.value} {...option} />
))}
<Filters.Empty>{(query) => `No matches for "${query}"`}</Filters.Empty>
</Filters.CheckboxOptions>
</Filters.Facet>
{/* Department — a second searchable tree. */}
<Filters.Facet facet="department">
<Filters.Search placeholder="Search departments" />
<Filters.Tree>
<Filters.GroupLabel>
<Filters.GroupTitle>Department</Filters.GroupTitle>
<Filters.BulkActions>
<Filters.SelectAll />
<Filters.Inverse />
<Filters.None />
</Filters.BulkActions>
</Filters.GroupLabel>
<Filters.TreeItem value="executive">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Executive</Filters.TreeItem.Label>
</Filters.TreeItem>
<Filters.TreeItem value="finance">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Finance</Filters.TreeItem.Label>
<Filters.TreeItem value="accounting">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Accounting</Filters.TreeItem.Label>
</Filters.TreeItem>
<Filters.TreeItem value="audit">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Audit</Filters.TreeItem.Label>
</Filters.TreeItem>
</Filters.TreeItem>
<Filters.TreeItem value="product">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Product</Filters.TreeItem.Label>
<Filters.TreeItem value="design">
<Filters.TreeItem.Twisty />
<Filters.TreeItem.Indicator />
<Filters.TreeItem.Label>Design</Filters.TreeItem.Label>
</Filters.TreeItem>
</Filters.TreeItem>
<Filters.Empty>{(query) => `No matches for "${query}"`}</Filters.Empty>
</Filters.Tree>
</Filters.Facet>
{/* Card — a searchable, grouped multi-select whose rows carry a trailing card-nickname meta. */}
<Filters.Facet facet="card">
<Filters.Search placeholder="Search cardholders" />
<Filters.CheckboxOptions>
<Filters.Group>
<Filters.GroupLabel>
<Filters.GroupTitle>Your cards</Filters.GroupTitle>
<Filters.BulkActions>
<Filters.SelectAll />
<Filters.Inverse />
<Filters.None />
</Filters.BulkActions>
</Filters.GroupLabel>
{yourCards.map((option) => (
<OptionRow key={option.value} {...option} />
))}
</Filters.Group>
<Filters.Group>
<Filters.GroupLabel>
<Filters.GroupTitle>Canceled cards</Filters.GroupTitle>
</Filters.GroupLabel>
{canceledCards.map((option) => (
<OptionRow key={option.value} {...option} />
))}
</Filters.Group>
<Filters.Empty>{(query) => `No matches for "${query}"`}</Filters.Empty>
</Filters.CheckboxOptions>
</Filters.Facet>
{/* Status — checkboxes whose description sits UNDER the label (a sibling in the text column). */}
<Filters.Facet facet="status">
<Filters.CheckboxOptions>
{statuses.map((status) => (
<Filters.Option key={status.value} value={status.value}>
<Filters.Option.Indicator />
<Filters.Option.Label>{status.label}</Filters.Option.Label>
<Filters.Option.Description>{status.description}</Filters.Option.Description>
</Filters.Option>
))}
</Filters.CheckboxOptions>
</Filters.Facet>
{/* Policy — a single boolean flag with its own sub-text. */}
<Filters.Facet facet="policy">
<Filters.Boolean>
<Filters.Boolean.Indicator />
<Filters.Boolean.Label>Missing required information</Filters.Boolean.Label>
<Filters.Boolean.Description>
Card transactions missing receipts, notes, or categories.
</Filters.Boolean.Description>
</Filters.Boolean>
</Filters.Facet>
{/* Attachment — a single-select radio. */}
<Filters.Facet facet="attachment">
<Filters.RadioOptions>
<Filters.Option value="any">
<Filters.Option.Indicator />
<Filters.Option.Label>Any</Filters.Option.Label>
</Filters.Option>
<Filters.Option value="has">
<Filters.Option.Indicator />
<Filters.Option.Label>Has attachment</Filters.Option.Label>
</Filters.Option>
<Filters.Option value="none">
<Filters.Option.Indicator />
<Filters.Option.Label>Does not have</Filters.Option.Label>
</Filters.Option>
</Filters.RadioOptions>
</Filters.Facet>
</Filters.Detail>
{/* Clear all lives in the title bar, so the pinned footer stays a single full-width
Apply — committing the draft closes the sheet in the same gesture. */}
<Filters.Footer>
<Filters.Apply>Apply</Filters.Apply>
</Filters.Footer>
</Filters.SheetPanel>
</Filters.Sheet>
</Filters.Root>
);
}
Keyboard
The panel is a master–detail composite: one tab stop moves into the rail, arrows move within it, and the detail pane's rich controls own their own keys.
| Key | Action |
|---|---|
Tab | Move between the trigger, the rail, the detail pane, and the actions |
Up / Down | Move between facets in the rail |
Enter / Right | Open the focused rail facet in the detail pane and move focus there |
Left / Escape | From the detail pane, return focus to the rail (rich controls consume these first) |
Home / End | Jump to the first / last facet in the rail |
Space | Toggle the focused checkbox or tree node |
Enter | Select the highlighted option in a combobox facet |
| Type | In a rail or a combobox, jump to the next matching label |
Arrow keys follow the visual order, so they read the same under RTL; directional glyphs and the forward/back keys mirror with direction.
Accessibility
- Master–detail semantics. The trigger carries
aria-expanded/aria-controls; the rail is alistboxofoptions witharia-selected; the detail pane is a labelledgroup; the actions are plain buttons. Focus moves into the panel on open and restores to the trigger on close. - Live results. A polite live region announces facet counts and the applied-filter summary, so a screen reader hears the effect of a change without a focus move.
- Rich controls. Option lists reuse the real
Checkbox/Radioprimitives (so selection, keyboard, and ARIA come for free); the tree exposesaria-checked="mixed"on tri-state parents. Each owns its keys — the shell yields when they handle an arrow or Escape. - Reduced motion. The panel open/close, the option transitions, and the sheet slide all respect
prefers-reduced-motion. - RTL. All geometry is logical — the rail, the tree indentation, the chevrons, and the grid mirror under
dir="rtl", and the forward/back keys swap with direction.
Anatomy
Compose a panel from structural parts — there is no schema. Filters.Root owns the draft / applied state (controlled via value / onValueChange, or uncontrolled via defaultValue) and the applyMode. Read and edit the state through useFilters() / useFiltersActions(), the per-facet useFacet() / useFacetOptions(), or the imperative ref.
Filters.Root— the headless, schemaless container. Props:value/defaultValue,onValueChange,onFiltersChange,onApply,applyMode,buildWhere,serializer,onUrlChange, and arefto the imperative handle. Renders no DOM.Filters.Menu/Filters.MenuTrigger(+TriggerIcon/TriggerLabel/Count) /Filters.MenuPanel— the popover shell.Filters.Header(+HeaderTitle) — an optional title bar; pair with aFilters.Clearfor a Reset.Filters.Rail+Filters.RailItem facet="…"(+RailItem.Icon/.Label/.Count/.Chevron) — thelistboxof facet triggers.Filters.Detail+Filters.Facet facet="…"— the detail pane and its lazily-mounted facet panels, linked to the rail items byfacet.Filters.Footer(+Spacer) withFilters.Clear/Filters.Revert/Filters.Apply— the staged-mode commit actions (also usable in the header).Filters.CheckboxOptions/Filters.RadioOptions+Filters.Option(+Option.Indicator/.Label/.Meta/.Count/.Description) — multi/single option lists.Filters.Search,Filters.Group(+GroupLabel/GroupTitle),Filters.BulkActions(+SelectAll/Inverse/None),Filters.Empty— the option-list extras.Filters.Tree+Filters.TreeItem value="…"(+TreeItem.Twisty/.Indicator/.Label/.Count) — the hierarchical facet.Filters.Range(+Range.Operator/.Min/.Max) andFilters.Boolean(+Boolean.Indicator/.Label/.Description) — the numeric and flag facets.Filters.QuickFilter— a pinned facet as a standalone popover chip.Filters.Sheet/Filters.SheetTrigger/Filters.SheetPanel— the mobile bottom-sheet variant.
Every rendered part carries a data-slot (filters-menu, filters-rail, filters-rail-item, filters-detail, filters-facet, filters-option, filters-tree-item, and the rest) for host-side styling — pair it with the state attributes (data-selected on the active facet, data-value on an option, and aria-checked="mixed" on a tri-state tree parent).
On surfaces
The same panel re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every panel in that region retunes. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table. Expand a row for the full type and description.