Noctis

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.

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.

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.

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.

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.

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.

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.

?

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:

TSX
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"
    ]
  }
]

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.HeaderFilters.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.

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.

KeyAction
TabMove between the trigger, the rail, the detail pane, and the actions
Up / DownMove between facets in the rail
Enter / RightOpen the focused rail facet in the detail pane and move focus there
Left / EscapeFrom the detail pane, return focus to the rail (rich controls consume these first)
Home / EndJump to the first / last facet in the rail
SpaceToggle the focused checkbox or tree node
EnterSelect the highlighted option in a combobox facet
TypeIn 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 a listbox of options with aria-selected; the detail pane is a labelled group; 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 / Radio primitives (so selection, keyboard, and ARIA come for free); the tree exposes aria-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 a ref to 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 a Filters.Clear for a Reset.
  • Filters.Rail + Filters.RailItem facet="…" (+ RailItem.Icon / .Label / .Count / .Chevron) — the listbox of facet triggers.
  • Filters.Detail + Filters.Facet facet="…" — the detail pane and its lazily-mounted facet panels, linked to the rail items by facet.
  • Filters.Footer (+ Spacer) with Filters.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) and Filters.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.

root
elevated
menu
sunken

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.

Token

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.

Filters.Root

Prop

Filters.Menu

Prop

Filters.MenuTrigger

Prop

Filters.TriggerIcon

Prop

Filters.TriggerLabel

Prop

Filters.Count

Prop

Filters.MenuPanel

Prop

Filters.Header

Prop

Filters.HeaderTitle

Prop

Filters.Rail

Prop

Filters.RailItem

Prop

Filters.Detail

Prop

Filters.Facet

Prop

Filters.Footer

Prop

Filters.Spacer

Prop

Filters.Summary

Prop

Filters.Clear

Prop

Filters.Revert

Prop

Filters.Apply

Prop

Filters.CheckboxOptions

Prop

Filters.RadioOptions

Prop

Filters.Option

Prop

Filters.Search

Prop

Filters.Group

Prop

Filters.GroupLabel

Prop

Filters.GroupTitle

Prop

Filters.BulkActions

Prop

Filters.SelectAll

Prop

Filters.Inverse

Prop

Filters.None

Prop

Filters.Empty

Prop

Filters.Tree

Prop

Filters.TreeItem

Prop

Filters.Range

Prop

Filters.Boolean

Prop

Filters.Sheet

Prop

Filters.SheetTrigger

Prop

Filters.SheetPanel

Prop

Filters.QuickFilter

Prop

AttributeDescription
data-slotThe root filters element.
data-selectedPresent on the active facet in the rail (the one the detail pane is configuring).
data-disabledPresent on a disabled facet or option.